Merge remote-tracking branch 'origin/develop' into update-emoji-mart

update-emoji-mart
Alex Gleason 2022-07-16 16:56:23 -05:00
commit 621a5a2e21
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 7211D1F99744FBB7
179 zmienionych plików z 3235 dodań i 1971 usunięć

Wyświetl plik

@ -0,0 +1,44 @@
[
{
"id": "1",
"content": "<p>Updated to Soapbox v3.</p>",
"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": "<p>Rolled back to Soapbox v2 for now.</p>",
"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
}
]
}
]

Wyświetl plik

@ -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<typeof mockStore>;
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<typeof mockStore>;
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);
});
});
});

Wyświetl plik

@ -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<typeof mockStore>;
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: '<p>note</p>',
verified: true,
},
];
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/truth/carousels/suggestions').reply(200, response, {
link: '<https://example.com/api/v1/truth/carousels/suggestions?since_id=1>; 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);
});
});
});
});

Wyświetl plik

@ -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,
});

Wyświetl plik

@ -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,

Wyświetl plik

@ -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<string, any> = {}, done = noOp) =>
const expandNotifications = ({ maxId }: Record<string, any> = {}, done: () => any = noOp) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return dispatch(noOp);

Wyświetl plik

@ -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<string, string>, baseURL?: string) =>
export const obtainOAuthToken = (params: Record<string, string | undefined>, baseURL?: string) =>
(dispatch: AppDispatch) => {
dispatch({ type: OAUTH_TOKEN_CREATE_REQUEST, params });
return baseClient(null, baseURL).post('/oauth/token', params).then(({ data: token }) => {

Wyświetl plik

@ -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);

Wyświetl plik

@ -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<string, any> = {}) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: SUGGESTIONS_FETCH_REQUEST, skipLoading: true });
@ -52,6 +59,48 @@ const fetchSuggestionsV2 = (params: Record<string, any> = {}) =>
});
};
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<string, any> = {}) =>
(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<SuggestedProfile[]>) => {
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<string, any> = { limit: 50 }) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
@ -59,17 +108,24 @@ const fetchSuggestions = (params: Record<string, any> = { 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<string, any> = { 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,
};

Wyświetl plik

@ -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<string, any[]> = {}, mode: 'any' | 'all' | 'none') => {
@ -139,9 +140,15 @@ const parseTags = (tags: Record<string, any[]> = {}, mode: 'any' | 'all' | 'none
const replaceHomeTimeline = (
accountId: string | null,
{ maxId }: Record<string, any> = {},
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<string, any> = {}, done = noOp) =>
@ -214,9 +221,9 @@ const expandGroupTimeline = (id: string, { maxId }: Record<string, any> = {}, do
const expandHashtagTimeline = (hashtag: string, { maxId, tags }: Record<string, any> = {}, 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,
};

Wyświetl plik

@ -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 (
<div data-testid='account' className='flex-shrink-0 group block w-full' ref={overflowRef}>
<HStack alignItems={actionAlignment} justifyContent='between'>
<HStack alignItems='center' space={3}>
<HStack alignItems={withAccountNote ? 'top' : 'center'} space={3}>
<ProfilePopper
condition={showProfileHoverCard}
wrapper={(children) => <HoverRefWrapper className='relative' accountId={account.id} inline>{children}</HoverRefWrapper>}
@ -202,35 +206,45 @@ const Account = ({
</LinkEl>
</ProfilePopper>
<HStack alignItems='center' space={1} style={style}>
<Text theme='muted' size='sm' truncate>@{username}</Text>
<Stack space={withAccountNote ? 1 : 0}>
<HStack alignItems='center' space={1} style={style}>
<Text theme='muted' size='sm' truncate>@{username}</Text>
{account.favicon && (
<InstanceFavicon account={account} />
)}
{account.favicon && (
<InstanceFavicon account={account} />
)}
{(timestamp) ? (
<>
<Text tag='span' theme='muted' size='sm'>&middot;</Text>
{(timestamp) ? (
<>
<Text tag='span' theme='muted' size='sm'>&middot;</Text>
{timestampUrl ? (
<Link to={timestampUrl} className='hover:underline'>
{timestampUrl ? (
<Link to={timestampUrl} className='hover:underline'>
<RelativeTimestamp timestamp={timestamp} theme='muted' size='sm' className='whitespace-nowrap' futureDate={futureTimestamp} />
</Link>
) : (
<RelativeTimestamp timestamp={timestamp} theme='muted' size='sm' className='whitespace-nowrap' futureDate={futureTimestamp} />
</Link>
) : (
<RelativeTimestamp timestamp={timestamp} theme='muted' size='sm' className='whitespace-nowrap' futureDate={futureTimestamp} />
)}
</>
) : null}
)}
</>
) : null}
{showEdit ? (
<>
<Text tag='span' theme='muted' size='sm'>&middot;</Text>
{showEdit ? (
<>
<Text tag='span' theme='muted' size='sm'>&middot;</Text>
<Icon className='h-5 w-5 stroke-[1.35]' src={require('@tabler/icons/pencil.svg')} />
</>
) : null}
</HStack>
<Icon className='h-5 w-5 stroke-[1.35]' src={require('@tabler/icons/pencil.svg')} />
</>
) : null}
</HStack>
{withAccountNote && (
<Text
size='sm'
dangerouslySetInnerHTML={{ __html: account.note_emojified }}
className='mr-2'
/>
)}
</Stack>
</div>
</HStack>

Wyświetl plik

@ -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<IAnimatedNumber> = ({ value, obfuscate }) => {
const reduceMotion = useSettings().get('reduceMotion');
const [direction, setDirection] = useState(1);
const [displayedValue, setDisplayedValue] = useState<number>(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)}</> : <FormattedNumber value={displayedValue} />;
}
const styles = [{
key: `${displayedValue}`,
data: displayedValue,
style: { y: spring(0, { damping: 35, stiffness: 400 }) },
}];
return (
<TransitionMotion styles={styles} willEnter={willEnter} willLeave={willLeave}>
{items => (
<span className='inline-flex flex-col items-stretch relative overflow-hidden'>
{items.map(({ key, data, style }) => (
<span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <FormattedNumber value={data} />}</span>
))}
</span>
)}
</TransitionMotion>
);
};
export default AnimatedNumber;

Wyświetl plik

@ -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<IAnnouncementContent> = ({ announcement }) => {
const history = useHistory();
const node = useRef<HTMLDivElement>(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 (
<div
className='translate text-sm'
ref={node}
dangerouslySetInnerHTML={{ __html: announcement.contentHtml }}
/>
);
};
export default AnnouncementContent;

Wyświetl plik

@ -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<string, ImmutableMap<string, string>>;
}
const Announcement: React.FC<IAnnouncement> = ({ 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 (
<Stack className='w-full' space={2}>
{hasTimeRange && (
<Text theme='muted'>
<FormattedDate
value={startsAt}
hour12={false}
year={(skipYear || startsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'}
month='short'
day='2-digit'
hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'}
/>
{' '}
-
{' '}
<FormattedDate
value={endsAt}
hour12={false}
year={(skipYear || endsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'}
month={skipEndDate ? undefined : 'short'}
day={skipEndDate ? undefined : '2-digit'}
hour={skipTime ? undefined : '2-digit'}
minute={skipTime ? undefined : '2-digit'}
/>
</Text>
)}
<AnnouncementContent announcement={announcement} />
{features.announcementsReactions && (
<ReactionsBar
reactions={announcement.reactions}
announcementId={announcement.id}
addReaction={addReaction}
removeReaction={removeReaction}
emojiMap={emojiMap}
/>
)}
</Stack>
);
};
export default Announcement;

Wyświetl plik

@ -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<ImmutableMap<string, string>>).reduce((map, emoji) => map.set(emoji.get('shortcode')!, emoji), ImmutableMap<string, ImmutableMap<string, string>>()));
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 (
<Widget title={<FormattedMessage id='announcements.title' defaultMessage='Announcements' />}>
<Card className='relative' size='md' variant='rounded'>
<ReactSwipeableViews animateHeight index={index} onChangeIndex={handleChangeIndex}>
{announcements.map((announcement) => (
<Announcement
key={announcement.id}
announcement={announcement}
emojiMap={emojiMap}
addReaction={addReaction}
removeReaction={removeReaction}
/>
)).reverse()}
</ReactSwipeableViews>
{announcements.size > 1 && (
<HStack space={2} alignItems='center' justifyContent='center' className='relative'>
{announcements.map((_, i) => (
<button
key={i}
tabIndex={0}
onClick={() => setIndex(i)}
className={classNames({
'w-2 h-2 rounded-full focus:ring-primary-600 focus:ring-2 focus:ring-offset-2': true,
'bg-gray-200 hover:bg-gray-300': i !== index,
'bg-primary-600': i === index,
})}
/>
))}
</HStack>
)}
</Card>
</Widget>
);
};
export default AnnouncementsPanel;

Wyświetl plik

@ -0,0 +1,51 @@
import React from 'react';
import unicodeMapping from 'soapbox/features/emoji/emoji_unicode_mapping_light';
import { useSettings } from 'soapbox/hooks';
import { joinPublicPath } from 'soapbox/utils/static';
import type { Map as ImmutableMap } from 'immutable';
interface IEmoji {
emoji: string;
emojiMap: ImmutableMap<string, ImmutableMap<string, string>>;
hovered: boolean;
}
const Emoji: React.FC<IEmoji> = ({ emoji, emojiMap, hovered }) => {
const autoPlayGif = useSettings().get('autoPlayGif');
// @ts-ignore
if (unicodeMapping[emoji]) {
// @ts-ignore
const { filename, shortCode } = unicodeMapping[emoji];
const title = shortCode ? `:${shortCode}:` : '';
return (
<img
draggable='false'
className='emojione block m-0'
alt={emoji}
title={title}
src={joinPublicPath(`packs/emoji/${filename}.svg`)}
/>
);
} else if (emojiMap.get(emoji as any)) {
const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']);
const shortCode = `:${emoji}:`;
return (
<img
draggable='false'
className='emojione block m-0'
alt={shortCode}
title={shortCode}
src={filename as string}
/>
);
} else {
return null;
}
};
export default Emoji;

Wyświetl plik

@ -0,0 +1,66 @@
import classNames from 'classnames';
import React, { useState } from 'react';
import AnimatedNumber from 'soapbox/components/animated-number';
import unicodeMapping from 'soapbox/features/emoji/emoji_unicode_mapping_light';
import Emoji from './emoji';
import type { Map as ImmutableMap } from 'immutable';
import type { AnnouncementReaction } from 'soapbox/types/entities';
interface IReaction {
announcementId: string;
reaction: AnnouncementReaction;
emojiMap: ImmutableMap<string, ImmutableMap<string, string>>;
addReaction: (id: string, name: string) => void;
removeReaction: (id: string, name: string) => void;
style: React.CSSProperties;
}
const Reaction: React.FC<IReaction> = ({ announcementId, reaction, addReaction, removeReaction, emojiMap, style }) => {
const [hovered, setHovered] = useState(false);
const handleClick = () => {
if (reaction.me) {
removeReaction(announcementId, reaction.name);
} else {
addReaction(announcementId, reaction.name);
}
};
const handleMouseEnter = () => setHovered(true);
const handleMouseLeave = () => setHovered(false);
let shortCode = reaction.name;
// @ts-ignore
if (unicodeMapping[shortCode]) {
// @ts-ignore
shortCode = unicodeMapping[shortCode].shortCode;
}
return (
<button
className={classNames('flex shrink-0 items-center gap-1.5 bg-gray-100 dark:bg-primary-900 rounded-sm px-1.5 py-1 transition-colors', {
'bg-gray-200 dark:bg-primary-800': hovered,
'bg-primary-200 dark:bg-primary-500': reaction.me,
})}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
title={`:${shortCode}:`}
style={style}
>
<span className='block h-4 w-4'>
<Emoji hovered={hovered} emoji={reaction.name} emojiMap={emojiMap} />
</span>
<span className='block min-w-[9px] text-center text-xs font-medium text-primary-600 dark:text-white'>
<AnimatedNumber value={reaction.count} />
</span>
</button>
);
};
export default Reaction;

Wyświetl plik

@ -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<AnnouncementReaction>;
emojiMap: ImmutableMap<string, ImmutableMap<string, string>>;
addReaction: (id: string, name: string) => void;
removeReaction: (id: string, name: string) => void;
}
const ReactionsBar: React.FC<IReactionsBar> = ({ 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 (
<TransitionMotion styles={styles} willEnter={willEnter} willLeave={willLeave}>
{items => (
<div className={classNames('flex flex-wrap items-center gap-1', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
{items.map(({ key, data, style }) => (
<Reaction
key={key}
reaction={data}
style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }}
announcementId={announcementId}
addReaction={addReaction}
removeReaction={removeReaction}
emojiMap={emojiMap}
/>
))}
{visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={handleEmojiPick} button={<Icon className='h-4 w-4 text-gray-400 hover:text-gray-600 dark:hover:text-white' src={require('@tabler/icons/plus.svg')} />} />}
</div>
)}
</TransitionMotion>
);
};
export default ReactionsBar;

Wyświetl plik

@ -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<string>,
disabled: boolean,
placeholder: string,
onSuggestionSelected: (tokenStart: number, token: string | null, value: string | undefined) => void,
onSuggestionsClearRequested: () => void,
onSuggestionsFetchRequested: (token: string | number) => void,
onChange: React.ChangeEventHandler<HTMLTextAreaElement>,
onKeyUp: React.KeyboardEventHandler<HTMLTextAreaElement>,
onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement>,
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<IAutosuggesteTextarea> {
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<HTMLTextAreaElement> = (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<HTMLTextAreaElement> = (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<HTMLDivElement> = (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<HTMLTextAreaElement> = (c) => {
this.textarea = c;
}
onPaste = (e) => {
onPaste: React.ClipboardEventHandler<HTMLTextAreaElement> = (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 (
<div
role='button'
tabIndex='0'
tabIndex={0}
key={key}
data-index={i}
className={classNames({
@ -272,7 +275,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
onFocus={this.onFocus}
onBlur={this.onBlur}
onPaste={this.onPaste}
style={style}
style={style as any}
aria-autocomplete='list'
/>
</label>
@ -297,3 +300,5 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
}
}
export default AutosuggestTextarea;

Wyświetl plik

@ -18,7 +18,7 @@ let id = 0;
export interface MenuItem {
action?: React.EventHandler<React.KeyboardEvent | React.MouseEvent>,
middleClick?: React.EventHandler<React.MouseEvent>,
text: string | JSX.Element,
text: string,
href?: string,
to?: string,
newTab?: boolean,

Wyświetl plik

@ -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 (
<i
role='img'
alt={alt}
className={classNames('fa', `fa-${id}`, className, { 'fa-fw': fixedWidth })}
{...other}
/>
);
}
}

Wyświetl plik

@ -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<HTMLLIElement> {
id: string,
className?: string,
fixedWidth?: boolean,
}
const ForkAwesomeIcon: React.FC<IForkAwesomeIcon> = ({ 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 (
<i
role='img'
// alt={alt}
className={classNames('fa', `fa-${id}`, className, { 'fa-fw': fixedWidth })}
{...rest}
/>
);
};
export default ForkAwesomeIcon;

Wyświetl plik

@ -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 <SvgIcon src={src} {...rest} />;
} else {
return <ForkAwesomeIcon id={id} fixedWidth={fixedWidth} {...rest} />;
}
}
}

Wyświetl plik

@ -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<IIcon> = (props) => {
if ((props as ISvgIcon).src) {
const { src, ...rest } = (props as ISvgIcon);
return <SvgIcon src={src} {...rest} />;
} else {
const { id, fixedWidth, ...rest } = (props as IForkAwesomeIcon);
return <ForkAwesomeIcon id={id} fixedWidth={fixedWidth} {...rest} />;
}
};
export default Icon;

Wyświetl plik

@ -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<HTMLDivElement> {
@ -12,7 +12,7 @@ interface IIconWithCounter extends React.HTMLAttributes<HTMLDivElement> {
const IconWithCounter: React.FC<IIconWithCounter> = ({ icon, count, ...rest }) => {
return (
<div className='relative'>
<Icon id={icon} {...rest} />
<Icon id={icon} {...rest as IIcon} />
{count > 0 && (
<i className='absolute -top-2 -right-2'>

Wyświetl plik

@ -137,6 +137,7 @@ const QuotedStatus: React.FC<IQuotedStatus> = ({ status, onCancel, compose }) =>
timestamp={status.created_at}
withRelationship={false}
showProfileHoverCard={!compose}
withLinkToProfile={!compose}
/>
{renderReplyMentions()}

Wyświetl plik

@ -34,6 +34,12 @@ const ScrollTopButton: React.FC<IScrollTopButton> = ({
const [scrolled, setScrolled] = useState<boolean>(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<IScrollTopButton> = ({
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 (
<div className={classes}>
<a className='flex items-center bg-primary-600 hover:bg-primary-700 hover:scale-105 active:scale-100 transition-transform text-white rounded-full px-4 py-2 space-x-1.5 cursor-pointer whitespace-nowrap' onClick={handleClick}>

Wyświetl plik

@ -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<any, any> {
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<any>,
/** Extra class names on the Virtuoso element. */
className?: string,
@ -244,20 +246,12 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
/>
);
/** Conditionally render inner elements. */
const renderBody = (): JSX.Element => {
if (isEmpty) {
return renderEmpty();
} else {
return renderFeed();
}
};
return (
<PullToRefresh onRefresh={onRefresh}>
{renderBody()}
</PullToRefresh>
);
// Conditionally render inner elements.
if (isEmpty) {
return renderEmpty();
} else {
return renderFeed();
}
});
export default ScrollableList;

Wyświetl plik

@ -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: <FormattedMessage id='navigation_bar.follow_requests' defaultMessage='Follow requests' />,
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: <FormattedMessage id='column.bookmarks' defaultMessage='Bookmarks' />,
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: <FormattedMessage id='column.lists' defaultMessage='Lists' />,
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: <FormattedMessage id='navigation.developers' defaultMessage='Developers' />,
text: intl.formatMessage(messages.developers),
});
}
@ -64,7 +76,7 @@ const SidebarNavigation = () => {
menu.push({
to: '/soapbox/admin',
icon: require('@tabler/icons/dashboard.svg'),
text: <FormattedMessage id='tabs_bar.dashboard' defaultMessage='Dashboard' />,
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 : <FormattedMessage id='tabs_bar.all' defaultMessage='All' />,
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: <FormattedMessage id='tabs_bar.fediverse' defaultMessage='Fediverse' />,
text: intl.formatMessage(messages.fediverse),
});
}

Wyświetl plik

@ -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<AccountEntity> = 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) => (
<a href='#' className='block py-2' onClick={handleSwitchAccount(account)} key={account.id}>
<div className='pointer-events-none'>
<Account account={account} showProfileHoverCard={false} withRelationship={false} />
<Account account={account} showProfileHoverCard={false} withRelationship={false} withLinkToProfile={false} />
</div>
</a>
);
@ -166,7 +166,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
<Stack space={1}>
<Link to={`/@${account.acct}`} onClick={onClose}>
<Account account={account} showProfileHoverCard={false} />
<Account account={account} showProfileHoverCard={false} withLinkToProfile={false} />
</Link>
<Stack>

Wyświetl plik

@ -132,11 +132,11 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
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<IStatus, IStatusState> {
hideActions={!reblogElement}
showEdit={!!status.edited_at}
showProfileHoverCard={this.props.hoverable}
withLinkToProfile={this.props.hoverable}
/>
</div>

Wyświetl plik

@ -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<IScrollableList, 'onLoadMore' | 'children'> {
/** 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<IStatusList> = ({
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<IStatusList> = ({
));
};
const renderFeedSuggestions = (): React.ReactNode => {
return <FeedSuggestions key='suggestions' />;
};
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 {

Wyświetl plik

@ -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 (
<CardHeader
aria-label={intl.formatMessage(messages.back)}
onBackClick={this.handleBackClick}
>
<CardTitle title={message} />
</CardHeader>
);
}
}

Wyświetl plik

@ -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<ISubNavigation> = ({ 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 (
<CardHeader
aria-label={intl.formatMessage(messages.back)}
onBackClick={handleBackClick}
>
<CardTitle title={message} />
</CardHeader>
);
};
export default SubNavigation;

Wyświetl plik

@ -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 (
<div
className={classNames('svg-icon', className)}
{...other}
>
<InlineSVG src={src} title={alt} loader={<></>} />
</div>
);
}
}

Wyświetl plik

@ -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<HTMLDivElement> {
src: string,
id?: string,
alt?: string,
className?: string,
}
const SvgIcon: React.FC<ISvgIcon> = ({ src, alt, className, ...rest }) => {
return (
<div
className={classNames('svg-icon', className)}
{...rest}
>
<InlineSVG src={src} title={alt} loader={<></>} />
</div>
);
};
export default SvgIcon;

Wyświetl plik

@ -21,7 +21,7 @@ const Tombstone: React.FC<ITombstone> = ({ id, onMoveUp, onMoveDown }) => {
<HotKeys handlers={handlers}>
<div className='p-9 flex items-center justify-center sm:rounded-xl bg-gray-100 border border-solid border-gray-200 dark:bg-slate-900 dark:border-slate-700 focusable' tabIndex={0}>
<Text>
<FormattedMessage id='statuses.tombstone' defaultMessage='One or more posts is unavailable.' />
<FormattedMessage id='statuses.tombstone' defaultMessage='One or more posts are unavailable.' />
</Text>
</div>
</HotKeys>

Wyświetl plik

@ -18,6 +18,8 @@ export interface IColumn {
withHeader?: boolean,
/** Extra class name for top <div> element. */
className?: string,
/** Ref forwarded to column. */
ref?: React.Ref<HTMLDivElement>
}
/** A backdrop for the main section of the UI. */

Wyświetl plik

@ -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 <div> 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. */

Wyświetl plik

@ -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';

Wyświetl plik

@ -20,7 +20,7 @@ interface IInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'maxL
className?: string,
/** Extra class names for the outer <div> 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<React.InputHTMLAttributes<HTMLInputElement>, 'maxL
/** Change event handler for the input. */
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => 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<HTMLInputElement, IInput>(
(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<HTMLInputElement, IInput>(
</div>
) : null}
{addon ? (
<div className='absolute inset-y-0 left-0 flex items-center'>
{addon}
</div>
) : null}
<input
{...filteredProps}
type={revealed ? 'text' : type}
@ -69,6 +77,7 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
'pr-7': isPassword,
'text-red-600 border-red-600': hasError,
'pl-8': typeof icon !== 'undefined',
'pl-16': typeof addon !== 'undefined',
}, className)}
/>

Wyświetl plik

@ -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<ICountryCodeDropdown> = ({ countryCode, onChange }) => {
return (
<select
value={countryCode}
className='h-full py-0 pl-3 pr-7 text-base bg-transparent border-transparent focus:outline-none focus:ring-primary-500 dark:text-white sm:text-sm rounded-md'
onChange={(event) => onChange(event.target.value as any)}
>
{COUNTRY_CODES.map((code) => (
<option value={code} key={code}>+{code}</option>
))}
</select>
);
};
export default CountryCodeDropdown;

Wyświetl plik

@ -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<React.InputHTMLAttributes<HTMLInputElement>, '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<IPhoneInput> = (props) => {
const { value, onChange, defaultCountryCode = '1', ...rest } = props;
const [countryCode, setCountryCode] = useState<CountryCode>(defaultCountryCode);
const [nationalNumber, setNationalNumber] = useState<string>('');
const handleChange: React.ChangeEventHandler<HTMLInputElement> = ({ 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 (
<Input
onChange={handleChange}
value={nationalNumber}
addon={
<CountryCodeDropdown
countryCode={countryCode}
onChange={setCountryCode}
/>
}
{...rest}
/>
);
};
export default PhoneInput;

Wyświetl plik

@ -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',

Wyświetl plik

@ -1,7 +1,7 @@
import classNames from 'classnames';
import React from 'react';
interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength' | 'onChange' | 'required' | 'disabled' | 'rows'> {
interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength' | 'onChange' | 'required' | 'disabled' | 'rows' | 'readOnly'> {
/** Put the cursor into the input on mount. */
autoFocus?: boolean,
/** The initial text in the input. */

Wyświetl plik

@ -84,7 +84,7 @@ const Aliases = () => {
<Text tag='span'>{alias}</Text>
</div>
<div className='flex items-center' role='button' tabIndex={0} onClick={handleFilterDelete} data-value={alias} aria-label={intl.formatMessage(messages.delete)}>
<Icon className='pr-1.5 text-lg' id='times' size={40} />
<Icon className='pr-1.5 text-lg' id='times' />
<Text weight='bold' theme='muted'><FormattedMessage id='aliases.aliases_list_delete' defaultMessage='Unlink alias' /></Text>
</div>
</HStack>

Wyświetl plik

@ -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 = () => {
<div className='px-4 pt-4 sm:p-0'>
<SubNavigation message={intl.formatMessage(messages.heading)} />
</div>
<StatusList
statusIds={statusIds}
scrollKey='bookmarked_statuses'
hasMore={hasMore}
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
onLoadMore={() => handleLoadMore(dispatch)}
onRefresh={handleRefresh}
emptyMessage={emptyMessage}
divideType='space'
/>
<PullToRefresh onRefresh={handleRefresh}>
<StatusList
statusIds={statusIds}
scrollKey='bookmarked_statuses'
hasMore={hasMore}
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
onLoadMore={() => handleLoadMore(dispatch)}
emptyMessage={emptyMessage}
divideType='space'
/>
</PullToRefresh>
</Column>
);
};

Wyświetl plik

@ -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 (
<Column label={intl.formatMessage(messages.title)} transparent>
<SubNavigation message={intl.formatMessage(messages.title)} settings={ColumnSettings} />
<Timeline
scrollKey={`${timelineId}_timeline`}
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}`}
onLoadMore={handleLoadMore}
onRefresh={handleRefresh}
emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />}
divideType='space'
/>
<PullToRefresh onRefresh={handleRefresh}>
<Timeline
scrollKey={`${timelineId}_timeline`}
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}`}
onLoadMore={handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />}
divideType='space'
/>
</PullToRefresh>
</Column>
);
};

Wyświetl plik

@ -39,6 +39,7 @@ const ReplyIndicator: React.FC<IReplyIndicator> = ({ status, hideActions, onCanc
id={status.getIn(['account', 'id']) as string}
timestamp={status.created_at}
showProfileHoverCard={false}
withLinkToProfile={false}
/>
<Text

Wyświetl plik

@ -1,231 +0,0 @@
import { Map as ImmutableMap } from 'immutable';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
import { connect } from 'react-redux';
import { createApp } from 'soapbox/actions/apps';
import { obtainOAuthToken } from 'soapbox/actions/oauth';
import { Button, Form, FormActions, FormGroup, Input, Stack, Text, Textarea } from 'soapbox/components/ui';
import Column from 'soapbox/features/ui/components/column';
import { getBaseURL } from 'soapbox/utils/accounts';
import { getFeatures } from 'soapbox/utils/features';
const messages = defineMessages({
heading: { id: 'column.app_create', defaultMessage: 'Create app' },
namePlaceholder: { id: 'app_create.name_placeholder', defaultMessage: 'e.g. \'Soapbox\'' },
scopesPlaceholder: { id: 'app_create.scopes_placeholder', defaultMessage: 'e.g. \'read write follow\'' },
});
const mapStateToProps = state => {
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 (
<Column label={intl.formatMessage(messages.heading)} backHref='/developers'>
<Form>
<Stack>
<Text size='lg' weight='medium'>
<FormattedMessage id='app_create.results.explanation_title' defaultMessage='App created successfully' />
</Text>
<Text theme='muted'>
<FormattedMessage
id='app_create.results.explanation_text'
defaultMessage='You created a new app and token! Please copy the credentials somewhere; you will not see them again after navigating away from this page.'
/>
</Text>
</Stack>
<FormGroup labelText={<FormattedMessage id='app_create.results.app_label' defaultMessage='App' />}>
<Textarea
value={JSON.stringify(app, null, 2)}
rows={10}
readOnly
isCodeEditor
/>
</FormGroup>
<FormGroup labelText={<FormattedMessage id='app_create.results.token_label' defaultMessage='OAuth token' />}>
<Textarea
value={JSON.stringify(token, null, 2)}
rows={10}
readOnly
isCodeEditor
/>
</FormGroup>
<FormActions>
<Button theme='primary' type='button' onClick={this.handleReset}>
<FormattedMessage id='app_create.restart' defaultMessage='Create another' />
</Button>
</FormActions>
</Form>
</Column>
);
}
render() {
const { intl } = this.props;
const { params, app, token, isLoading } = this.state;
if (app && token) {
return this.renderResults();
}
return (
<Column label={intl.formatMessage(messages.heading)} backHref='/developers'>
<Form onSubmit={this.handleSubmit}>
<FormGroup labelText={<FormattedMessage id='app_create.name_label' defaultMessage='App name' />}>
<Input
placeholder={intl.formatMessage(messages.namePlaceholder)}
onChange={this.handleParamChange('client_name')}
value={params.get('client_name')}
required
/>
</FormGroup>
<FormGroup labelText={<FormattedMessage id='app_create.website_label' defaultMessage='Website' />}>
<Input
placeholder='https://soapbox.pub'
onChange={this.handleParamChange('website')}
value={params.get('website')}
/>
</FormGroup>
<FormGroup labelText={<FormattedMessage id='app_create.redirect_uri_label' defaultMessage='Redirect URIs' />}>
<Input
placeholder='https://example.com'
onChange={this.handleParamChange('redirect_uris')}
value={params.get('redirect_uris')}
required
/>
</FormGroup>
<FormGroup labelText={<FormattedMessage id='app_create.scopes_label' defaultMessage='Scopes' />}>
<Input
placeholder={intl.formatMessage(messages.scopesPlaceholder)}
onChange={this.handleParamChange('scopes')}
value={params.get('scopes')}
required
/>
</FormGroup>
<FormActions>
<Button theme='primary' type='submit' disabled={isLoading}>
<FormattedMessage id='app_create.submit' defaultMessage='Create app' />
</Button>
</FormActions>
</Form>
</Column>
);
}
}

Wyświetl plik

@ -0,0 +1,201 @@
import React, { useState } from 'react';
import { useIntl, FormattedMessage, defineMessages } from 'react-intl';
import { createApp } from 'soapbox/actions/apps';
import { obtainOAuthToken } from 'soapbox/actions/oauth';
import { Button, Form, FormActions, FormGroup, Input, Stack, Text, Textarea } from 'soapbox/components/ui';
import Column from 'soapbox/features/ui/components/column';
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
import { getBaseURL } from 'soapbox/utils/accounts';
const messages = defineMessages({
heading: { id: 'column.app_create', defaultMessage: 'Create app' },
namePlaceholder: { id: 'app_create.name_placeholder', defaultMessage: 'e.g. \'Soapbox\'' },
scopesPlaceholder: { id: 'app_create.scopes_placeholder', defaultMessage: 'e.g. \'read write follow\'' },
});
const BLANK_PARAMS = {
client_name: '',
redirect_uris: 'urn:ietf:wg:oauth:2.0:oob',
scopes: '',
website: '',
};
type Params = typeof BLANK_PARAMS;
const CreateApp: React.FC = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const account = useOwnAccount();
const [app, setApp] = useState<Record<string, any> | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setLoading] = useState(false);
const [params, setParams] = useState<Params>(BLANK_PARAMS);
const handleCreateApp = () => {
const baseURL = getBaseURL(account!);
return dispatch(createApp(params, baseURL))
.then(app => {
setApp(app);
return app;
});
};
const handleCreateToken = (app: Record<string, string>) => {
const baseURL = getBaseURL(account!);
const tokenParams = {
client_id: app!.client_id,
client_secret: app!.client_secret,
redirect_uri: params.redirect_uris,
grant_type: 'client_credentials',
scope: params.scopes,
};
return dispatch(obtainOAuthToken(tokenParams, baseURL))
.then(setToken);
};
const handleSubmit = () => {
setLoading(true);
handleCreateApp()
.then(handleCreateToken)
.then(() => {
scrollToTop();
setLoading(false);
}).catch(error => {
console.error(error);
setLoading(false);
});
};
const setParam = (key: string, value: string) => {
setParams({ ...params, [key]: value });
};
const handleParamChange = (key: string): React.ChangeEventHandler<HTMLInputElement> => {
return e => {
setParam(key, e.target.value);
};
};
const resetState = () => {
setApp(null);
setToken(null);
setLoading(false);
setParams(BLANK_PARAMS);
};
const handleReset = () => {
resetState();
scrollToTop();
};
const scrollToTop = (): void => {
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const renderResults = () => {
return (
<Column label={intl.formatMessage(messages.heading)} backHref='/developers'>
<Form>
<Stack>
<Text size='lg' weight='medium'>
<FormattedMessage id='app_create.results.explanation_title' defaultMessage='App created successfully' />
</Text>
<Text theme='muted'>
<FormattedMessage
id='app_create.results.explanation_text'
defaultMessage='You created a new app and token! Please copy the credentials somewhere; you will not see them again after navigating away from this page.'
/>
</Text>
</Stack>
<FormGroup labelText={<FormattedMessage id='app_create.results.app_label' defaultMessage='App' />}>
<Textarea
value={JSON.stringify(app, null, 2)}
rows={10}
readOnly
isCodeEditor
/>
</FormGroup>
<FormGroup labelText={<FormattedMessage id='app_create.results.token_label' defaultMessage='OAuth token' />}>
<Textarea
value={JSON.stringify(token, null, 2)}
rows={10}
readOnly
isCodeEditor
/>
</FormGroup>
<FormActions>
<Button theme='primary' type='button' onClick={handleReset}>
<FormattedMessage id='app_create.restart' defaultMessage='Create another' />
</Button>
</FormActions>
</Form>
</Column>
);
};
if (app && token) {
return renderResults();
}
return (
<Column label={intl.formatMessage(messages.heading)} backHref='/developers'>
<Form onSubmit={handleSubmit}>
<FormGroup labelText={<FormattedMessage id='app_create.name_label' defaultMessage='App name' />}>
<Input
type='text'
placeholder={intl.formatMessage(messages.namePlaceholder)}
onChange={handleParamChange('client_name')}
value={params.client_name}
required
/>
</FormGroup>
<FormGroup labelText={<FormattedMessage id='app_create.website_label' defaultMessage='Website' />}>
<Input
type='text'
placeholder='https://soapbox.pub'
onChange={handleParamChange('website')}
value={params.website}
/>
</FormGroup>
<FormGroup labelText={<FormattedMessage id='app_create.redirect_uri_label' defaultMessage='Redirect URIs' />}>
<Input
type='text'
placeholder='https://example.com'
onChange={handleParamChange('redirect_uris')}
value={params.redirect_uris}
required
/>
</FormGroup>
<FormGroup labelText={<FormattedMessage id='app_create.scopes_label' defaultMessage='Scopes' />}>
<Input
type='text'
placeholder={intl.formatMessage(messages.scopesPlaceholder)}
onChange={handleParamChange('scopes')}
value={params.scopes}
required
/>
</FormGroup>
<FormActions>
<Button theme='primary' type='submit' disabled={isLoading}>
<FormattedMessage id='app_create.submit' defaultMessage='Create app' />
</Button>
</FormActions>
</Form>
</Column>
);
};
export default CreateApp;

Wyświetl plik

@ -3,8 +3,8 @@ import React from 'react';
import { getSettings } from 'soapbox/actions/settings';
import { useAppSelector } from 'soapbox/hooks';
import DevelopersChallenge from './developers_challenge';
import DevelopersMenu from './developers_menu';
import DevelopersChallenge from './developers-challenge';
import DevelopersMenu from './developers-menu';
const Developers: React.FC = () => {
const isDeveloper = useAppSelector((state) => getSettings(state).get('isDeveloper'));

Wyświetl plik

@ -0,0 +1,153 @@
import React, { useState, useEffect } from 'react';
import { useIntl, FormattedMessage, defineMessages } from 'react-intl';
import { showAlertForError } from 'soapbox/actions/alerts';
import { patchMe } from 'soapbox/actions/me';
import { FE_NAME, SETTINGS_UPDATE, changeSetting } from 'soapbox/actions/settings';
import List, { ListItem } from 'soapbox/components/list';
import {
CardHeader,
CardTitle,
Column,
Button,
Form,
FormActions,
FormGroup,
Textarea,
} from 'soapbox/components/ui';
import SettingToggle from 'soapbox/features/notifications/components/setting_toggle';
import { useAppSelector, useAppDispatch, useSettings } from 'soapbox/hooks';
const isJSONValid = (text: any): boolean => {
try {
JSON.parse(text);
return true;
} catch {
return false;
}
};
const messages = defineMessages({
heading: { id: 'column.settings_store', defaultMessage: 'Settings store' },
hint: { id: 'developers.settings_store.hint', defaultMessage: 'It is possible to directly edit your user settings here. BE CAREFUL! Editing this section can break your account, and you will only be able to recover through the API.' },
});
const SettingsStore: React.FC = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const settings = useSettings();
const settingsStore = useAppSelector(state => state.get('settings'));
const [rawJSON, setRawJSON] = useState<string>(JSON.stringify(settingsStore, null, 2));
const [jsonValid, setJsonValid] = useState(true);
const [isLoading, setLoading] = useState(false);
const handleEditJSON: React.ChangeEventHandler<HTMLTextAreaElement> = ({ target }) => {
const rawJSON = target.value;
setRawJSON(rawJSON);
setJsonValid(isJSONValid(rawJSON));
};
const onToggleChange = (key: string[], checked: boolean) => {
dispatch(changeSetting(key, checked, { showAlert: true }));
};
const handleSubmit: React.FormEventHandler = e => {
const settings = JSON.parse(rawJSON);
setLoading(true);
dispatch(patchMe({
pleroma_settings_store: {
[FE_NAME]: settings,
},
})).then(response => {
dispatch({ type: SETTINGS_UPDATE, settings });
setLoading(false);
}).catch(error => {
dispatch(showAlertForError(error));
setLoading(false);
});
};
useEffect(() => {
setRawJSON(JSON.stringify(settingsStore, null, 2));
setJsonValid(true);
}, [settingsStore]);
return (
<Column label={intl.formatMessage(messages.heading)} backHref='/developers'>
<Form onSubmit={handleSubmit}>
<FormGroup
hintText={intl.formatMessage(messages.hint)}
errors={jsonValid ? [] : ['is invalid']}
>
<Textarea
value={rawJSON}
onChange={handleEditJSON}
disabled={isLoading}
rows={12}
isCodeEditor
/>
</FormGroup>
<FormActions>
<Button theme='primary' type='submit' disabled={!jsonValid || isLoading}>
<FormattedMessage id='soapbox_config.save' defaultMessage='Save' />
</Button>
</FormActions>
</Form>
<CardHeader>
<CardTitle title='Advanced settings' />
</CardHeader>
<List>
<ListItem label={<FormattedMessage id='preferences.notifications.advanced' defaultMessage='Show all notification categories' />}>
<SettingToggle settings={settings} settingPath={['notifications', 'quickFilter', 'advanced']} onChange={onToggleChange} />
</ListItem>
<ListItem label={<FormattedMessage id='preferences.fields.unfollow_modal_label' defaultMessage='Show confirmation dialog before unfollowing someone' />}>
<SettingToggle settings={settings} settingPath={['unfollowModal']} onChange={onToggleChange} />
</ListItem>
<ListItem label={<FormattedMessage id='preferences.fields.missing_description_modal_label' defaultMessage='Show confirmation dialog before sending a post without media descriptions' />}>
<SettingToggle settings={settings} settingPath={['missingDescriptionModal']} onChange={onToggleChange} />
</ListItem>
<ListItem label={<FormattedMessage id='preferences.fields.reduce_motion_label' defaultMessage='Reduce motion in animations' />}>
<SettingToggle settings={settings} settingPath={['reduceMotion']} onChange={onToggleChange} />
</ListItem>
<ListItem label={<FormattedMessage id='preferences.fields.underline_links_label' defaultMessage='Always underline links in posts' />}>
<SettingToggle settings={settings} settingPath={['underlineLinks']} onChange={onToggleChange} />
</ListItem>
<ListItem label={<FormattedMessage id='preferences.fields.system_font_label' defaultMessage="Use system's default font" />}>
<SettingToggle settings={settings} settingPath={['systemFont']} onChange={onToggleChange} />
</ListItem>
<div className='dyslexic'>
<ListItem label={<FormattedMessage id='preferences.fields.dyslexic_font_label' defaultMessage='Dyslexic mode' />}>
<SettingToggle settings={settings} settingPath={['dyslexicFont']} onChange={onToggleChange} />
</ListItem>
</div>
{/* <ListItem
label={<FormattedMessage id='preferences.fields.halloween_label' defaultMessage='Halloween mode' />}
hint={<FormattedMessage id='preferences.hints.halloween' defaultMessage='Beware: SPOOKY! Supports light/dark toggle.' />}
>
<SettingToggle settings={settings} settingPath={['halloween']} onChange={onToggleChange} />
</ListItem> */}
<ListItem
label={<FormattedMessage id='preferences.fields.demetricator_label' defaultMessage='Use Demetricator' />}
hint={<FormattedMessage id='preferences.hints.demetricator' defaultMessage='Decrease social media anxiety by hiding all numbers from the site.' />}
>
<SettingToggle settings={settings} settingPath={['demetricator']} onChange={onToggleChange} />
</ListItem>
</List>
</Column>
);
};
export default SettingsStore;

Wyświetl plik

@ -1,116 +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 { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
import { connect } from 'react-redux';
import { showAlertForError } from 'soapbox/actions/alerts';
import { patchMe } from 'soapbox/actions/me';
import { FE_NAME, SETTINGS_UPDATE } from 'soapbox/actions/settings';
import { Button, Form, FormActions, FormGroup, Textarea } from 'soapbox/components/ui';
import Column from 'soapbox/features/ui/components/column';
const isJSONValid = text => {
try {
JSON.parse(text);
return true;
} catch {
return false;
}
};
const messages = defineMessages({
heading: { id: 'column.settings_store', defaultMessage: 'Settings store' },
hint: { id: 'developers.settings_store.hint', defaultMessage: 'It is possible to directly edit your user settings here. BE CAREFUL! Editing this section can break your account, and you will only be able to recover through the API.' },
});
const mapStateToProps = state => {
return {
settingsStore: state.get('settings'),
};
};
export default @connect(mapStateToProps)
@injectIntl
class SettingsStore extends ImmutablePureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
settingsStore: ImmutablePropTypes.map.isRequired,
}
state = {
rawJSON: JSON.stringify(this.props.settingsStore, null, 2),
jsonValid: true,
isLoading: false,
}
componentDidUpdate(prevProps) {
const { settingsStore } = this.props;
if (settingsStore !== prevProps.settingsStore) {
this.setState({
rawJSON: JSON.stringify(settingsStore, null, 2),
jsonValid: true,
});
}
}
handleEditJSON = ({ target }) => {
const rawJSON = target.value;
this.setState({ rawJSON, jsonValid: isJSONValid(rawJSON) });
}
handleSubmit = e => {
const { dispatch } = this.props;
const { rawJSON } = this.state;
const settings = JSON.parse(rawJSON);
this.setState({ isLoading: true });
dispatch(patchMe({
pleroma_settings_store: {
[FE_NAME]: settings,
},
})).then(response => {
dispatch({ type: SETTINGS_UPDATE, settings });
this.setState({ isLoading: false });
}).catch(error => {
dispatch(showAlertForError(error));
this.setState({ isLoading: false });
});
}
render() {
const { intl } = this.props;
const { rawJSON, jsonValid, isLoading } = this.state;
return (
<Column label={intl.formatMessage(messages.heading)} backHref='/developers'>
<Form onSubmit={this.handleSubmit}>
<FormGroup
hintText={intl.formatMessage(messages.hint)}
errors={jsonValid ? [] : ['is invalid']}
>
<Textarea
value={rawJSON}
onChange={this.handleEditJSON}
disabled={isLoading}
rows={12}
isCodeEditor
/>
</FormGroup>
<FormActions>
<Button theme='primary' type='submit' disabled={!jsonValid || isLoading}>
<FormattedMessage id='soapbox_config.save' defaultMessage='Save' />
</Button>
</FormActions>
</Form>
</Column>
);
}
}

Wyświetl plik

@ -71,7 +71,7 @@ const mapStateToProps = state => ({
frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
});
const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
const mapDispatchToProps = (dispatch, props) => ({
onSkinTone: skinTone => {
dispatch(changeSetting(['skinTone'], skinTone));
},
@ -79,8 +79,8 @@ const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
onPickEmoji: emoji => {
dispatch(useEmoji(emoji)); // eslint-disable-line react-hooks/rules-of-hooks
if (onPickEmoji) {
onPickEmoji(emoji);
if (props.onPickEmoji) {
props.onPickEmoji(emoji);
}
},
});

Wyświetl plik

@ -50,8 +50,8 @@ class InstanceRestrictions extends ImmutablePureComponent {
if (followers_only) {
items.push((
<Text key='followers_only'>
<Icon className='mr-2' src={require('@tabler/icons/lock.svg')} />
<Text key='followers_only' className='flex items-center gap-2' theme='muted'>
<Icon src={require('@tabler/icons/lock.svg')} />
<FormattedMessage
id='federation_restriction.followers_only'
defaultMessage='Hidden except to followers'
@ -60,8 +60,8 @@ class InstanceRestrictions extends ImmutablePureComponent {
));
} else if (federated_timeline_removal) {
items.push((
<Text key='federated_timeline_removal'>
<Icon className='mr-2' src={require('@tabler/icons/lock-open.svg')} />
<Text key='federated_timeline_removal' className='flex items-center gap-2' theme='muted'>
<Icon src={require('@tabler/icons/lock-open.svg')} />
<FormattedMessage
id='federation_restriction.federated_timeline_removal'
defaultMessage='Fediverse timeline removal'
@ -72,8 +72,8 @@ class InstanceRestrictions extends ImmutablePureComponent {
if (fullMediaRemoval) {
items.push((
<Text key='full_media_removal'>
<Icon className='mr-2' src={require('@tabler/icons/photo-off.svg')} />
<Text key='full_media_removal' className='flex items-center gap-2' theme='muted'>
<Icon src={require('@tabler/icons/photo-off.svg')} />
<FormattedMessage
id='federation_restriction.full_media_removal'
defaultMessage='Full media removal'
@ -82,8 +82,8 @@ class InstanceRestrictions extends ImmutablePureComponent {
));
} else if (partialMediaRemoval) {
items.push((
<Text key='partial_media_removal'>
<Icon className='mr-2' src={require('@tabler/icons/photo-off.svg')} />
<Text key='partial_media_removal' className='flex items-center gap-2' theme='muted'>
<Icon src={require('@tabler/icons/photo-off.svg')} />
<FormattedMessage
id='federation_restriction.partial_media_removal'
defaultMessage='Partial media removal'
@ -94,8 +94,8 @@ class InstanceRestrictions extends ImmutablePureComponent {
if (!fullMediaRemoval && media_nsfw) {
items.push((
<Text key='media_nsfw'>
<Icon className='mr-2' id='eye-slash' />
<Text key='media_nsfw' className='flex items-center gap-2' theme='muted'>
<Icon id='eye-slash' />
<FormattedMessage
id='federation_restriction.media_nsfw'
defaultMessage='Attachments marked NSFW'
@ -116,8 +116,8 @@ class InstanceRestrictions extends ImmutablePureComponent {
if (remoteInstance.getIn(['federation', 'reject']) === true) {
return (
<Text>
<Icon className='mr-2' id='times' />
<Text className='flex items-center gap-2' theme='muted'>
<Icon id='times' />
<FormattedMessage
id='remote_instance.federation_panel.restricted_message'
defaultMessage='{siteTitle} blocks all activities from {host}.'
@ -128,7 +128,7 @@ class InstanceRestrictions extends ImmutablePureComponent {
} else if (hasRestrictions(remoteInstance)) {
return [
(
<Text>
<Text theme='muted'>
<FormattedMessage
id='remote_instance.federation_panel.some_restrictions_message'
defaultMessage='{siteTitle} has placed some restrictions on {host}.'
@ -140,8 +140,8 @@ class InstanceRestrictions extends ImmutablePureComponent {
];
} else {
return (
<Text>
<Icon className='mr-2' id='check' />
<Text className='flex items-center gap-2' theme='muted'>
<Icon id='check' />
<FormattedMessage
id='remote_instance.federation_panel.no_restrictions_message'
defaultMessage='{siteTitle} has placed no restrictions on {host}.'
@ -153,7 +153,11 @@ class InstanceRestrictions extends ImmutablePureComponent {
}
render() {
return <div className='instance-restrictions'>{this.renderContent()}</div>;
return (
<div className='py-1 pl-4 mb-4 border-solid border-l-[3px] border-gray-300 dark:border-gray-500'>
{this.renderContent()}
</div>
);
}
}

Wyświetl plik

@ -24,20 +24,19 @@ const RestrictedInstance: React.FC<IRestrictedInstance> = ({ host }) => {
};
return (
<div className={classNames('restricted-instance', {
'restricted-instance--reject': remoteInstance.getIn(['federation', 'reject']),
'restricted-instance--expanded': expanded,
})}
>
<a href='#' className='restricted-instance__header' onClick={toggleExpanded}>
<div className='restricted-instance__icon'>
<Icon src={expanded ? require('@tabler/icons/caret-down.svg') : require('@tabler/icons/caret-right.svg')} />
</div>
<div className='restricted-instance__host'>
<div>
<a href='#' className='flex items-center gap-1 py-2.5 no-underline' onClick={toggleExpanded}>
<Icon src={expanded ? require('@tabler/icons/caret-down.svg') : require('@tabler/icons/caret-right.svg')} />
<div className={classNames({ 'line-through': remoteInstance.getIn(['federation', 'reject']) })}>
{remoteInstance.get('host')}
</div>
</a>
<div className='restricted-instance__restrictions'>
<div
className={classNames({
'h-0 overflow-hidden': !expanded,
'h-auto': expanded,
})}
>
<InstanceRestrictions remoteInstance={remoteInstance} />
</div>
</div>

Wyświetl plik

@ -40,17 +40,15 @@ const FederationRestrictions = () => {
return (
<Column icon='gavel' label={intl.formatMessage(messages.heading)}>
<div className='explanation-box'>
<Accordion
headline={intl.formatMessage(messages.boxTitle)}
expanded={explanationBoxExpanded}
onToggle={toggleExplanationBox}
>
{intl.formatMessage(messages.boxMessage, { siteTitle })}
</Accordion>
</div>
<Accordion
headline={intl.formatMessage(messages.boxTitle)}
expanded={explanationBoxExpanded}
onToggle={toggleExplanationBox}
>
{intl.formatMessage(messages.boxMessage, { siteTitle })}
</Accordion>
<div className='federation-restrictions'>
<div className='pt-4'>
<ScrollableList emptyMessage={intl.formatMessage(emptyMessage, { siteTitle })}>
{hosts.map((host) => <RestrictedInstance key={host} host={host} />)}
</ScrollableList>

Wyświetl plik

@ -56,11 +56,31 @@ describe('<FeedCarousel />', () => {
});
it('should render the Carousel', () => {
store.carousels = {
avatars: [
{ account_id: '1', acct: 'a', account_avatar: 'https://example.com/some.jpg' },
],
};
render(<FeedCarousel />, undefined, store);
expect(screen.queryAllByTestId('feed-carousel')).toHaveLength(1);
});
describe('with 0 avatars', () => {
beforeEach(() => {
store.carousels = {
avatars: [],
};
});
it('renders the error message', () => {
render(<FeedCarousel />, undefined, store);
expect(screen.queryAllByTestId('feed-carousel-error')).toHaveLength(0);
});
});
describe('with a failed request to the API', () => {
beforeEach(() => {
store.carousels = {

Wyświetl plik

@ -15,13 +15,24 @@ const CarouselItem = ({ avatar }: { avatar: any }) => {
const selectedAccountId = useAppSelector(state => state.timelines.get('home')?.feedAccountId);
const isSelected = avatar.account_id === selectedAccountId;
const handleClick = () =>
isSelected
? dispatch(replaceHomeTimeline(null, { maxId: null }))
: dispatch(replaceHomeTimeline(avatar.account_id, { maxId: null }));
const [isLoading, setLoading] = useState<boolean>(false);
const handleClick = () => {
if (isLoading) {
return;
}
setLoading(true);
if (isSelected) {
dispatch(replaceHomeTimeline(null, { maxId: null }, () => setLoading(false)));
} else {
dispatch(replaceHomeTimeline(avatar.account_id, { maxId: null }, () => setLoading(false)));
}
};
return (
<div onClick={handleClick} className='cursor-pointer' role='filter-feed-by-user'>
<div aria-disabled={isLoading} onClick={handleClick} className='cursor-pointer' role='filter-feed-by-user'>
<Stack className='w-16 h-auto' space={3}>
<div className='block mx-auto relative w-14 h-14 rounded-full'>
{isSelected && (
@ -41,7 +52,7 @@ const CarouselItem = ({ avatar }: { avatar: any }) => {
/>
</div>
<Text theme='muted' size='sm' truncate align='center' className='leading-3'>{avatar.acct}</Text>
<Text theme='muted' size='sm' truncate align='center' className='leading-3 pb-0.5'>{avatar.acct}</Text>
</Stack>
</div>
);
@ -93,6 +104,10 @@ const FeedCarousel = () => {
);
}
if (avatars.length === 0) {
return null;
}
return (
<Card variant='rounded' size='lg' ref={cardRef} className='relative' data-testid='feed-carousel'>
<div>

Wyświetl plik

@ -0,0 +1,92 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import VerificationBadge from 'soapbox/components/verification_badge';
import { useAccount, useAppSelector } from 'soapbox/hooks';
import { Card, CardBody, CardTitle, HStack, Stack, Text } from '../../components/ui';
import ActionButton from '../ui/components/action-button';
import type { Account } from 'soapbox/types/entities';
const messages = defineMessages({
heading: { id: 'feed_suggestions.heading', defaultMessage: 'Suggested profiles' },
viewAll: { id: 'feed_suggestions.view_all', defaultMessage: 'View all' },
});
const SuggestionItem = ({ accountId }: { accountId: string }) => {
const account = useAccount(accountId) as Account;
return (
<Stack className='w-24 h-auto' space={3}>
<Link
to={`/@${account.acct}`}
title={account.acct}
>
<Stack space={3}>
<img
src={account.avatar}
className='mx-auto block w-16 h-16 min-w-[56px] rounded-full'
alt={account.acct}
/>
<Stack>
<HStack alignItems='center' justifyContent='center' space={1}>
<Text
weight='semibold'
dangerouslySetInnerHTML={{ __html: account.display_name }}
truncate
align='center'
size='sm'
className='max-w-[95%]'
/>
{account.verified && <VerificationBadge />}
</HStack>
<Text theme='muted' align='center' size='sm' truncate>@{account.acct}</Text>
</Stack>
</Stack>
</Link>
<div className='text-center'>
<ActionButton account={account} />
</div>
</Stack>
);
};
const FeedSuggestions = () => {
const intl = useIntl();
const suggestedProfiles = useAppSelector((state) => state.suggestions.items);
return (
<Card size='lg' variant='rounded'>
<HStack justifyContent='between' alignItems='center'>
<CardTitle title={intl.formatMessage(messages.heading)} />
<Link
to='/suggestions'
className='text-primary-600 dark:text-primary-400 hover:underline'
>
{intl.formatMessage(messages.viewAll)}
</Link>
</HStack>
<CardBody>
<HStack
alignItems='center'
justifyContent='around'
space={8}
>
{suggestedProfiles.slice(0, 4).map((suggestedProfile) => (
<SuggestionItem key={suggestedProfile.account} accountId={suggestedProfile.account} />
))}
</HStack>
</CardBody>
</Card>
);
};
export default FeedSuggestions;

Wyświetl plik

@ -216,7 +216,7 @@ const Filters = () => {
</div>
</div>
<div className='filter__delete' role='button' tabIndex={0} onClick={handleFilterDelete} data-value={filter.id} aria-label={intl.formatMessage(messages.delete)}>
<Icon className='filter__delete-icon' id='times' size={40} />
<Icon className='filter__delete-icon' id='times' />
<span className='filter__delete-label'><FormattedMessage id='filters.filters_list_delete' defaultMessage='Delete' /></span>
</div>
</div>

Wyświetl plik

@ -0,0 +1,82 @@
import debounce from 'lodash/debounce';
import React, { useEffect } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { fetchSuggestions } from 'soapbox/actions/suggestions';
import ScrollableList from 'soapbox/components/scrollable_list';
import { Stack, Text } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
import Column from 'soapbox/features/ui/components/column';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
const messages = defineMessages({
heading: { id: 'followRecommendations.heading', defaultMessage: 'Suggested profiles' },
});
const FollowRecommendations: React.FC = () => {
const dispatch = useAppDispatch();
const intl = useIntl();
const features = useFeatures();
const suggestions = useAppSelector((state) => state.suggestions.items);
const hasMore = useAppSelector((state) => !!state.suggestions.next);
const isLoading = useAppSelector((state) => state.suggestions.isLoading);
const handleLoadMore = debounce(() => {
if (isLoading) {
return null;
}
return dispatch(fetchSuggestions({ limit: 20 }));
}, 300);
useEffect(() => {
dispatch(fetchSuggestions({ limit: 20 }));
}, []);
if (suggestions.size === 0 && !isLoading) {
return (
<Column label={intl.formatMessage(messages.heading)}>
<Text align='center'>
<FormattedMessage id='empty_column.follow_recommendations' defaultMessage='Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.' />
</Text>
</Column>
);
}
return (
<Column label={intl.formatMessage(messages.heading)}>
<Stack space={4}>
<ScrollableList
isLoading={isLoading}
scrollKey='suggestions'
onLoadMore={handleLoadMore}
hasMore={hasMore}
itemClassName='pb-4'
>
{features.truthSuggestions ? (
suggestions.map((suggestedProfile) => (
<AccountContainer
key={suggestedProfile.account}
id={suggestedProfile.account}
withAccountNote
showProfileHoverCard={false}
actionAlignment='top'
/>
))
) : (
suggestions.map((suggestion) => (
<AccountContainer
key={suggestion.account}
id={suggestion.account}
withAccountNote
/>
))
)}
</ScrollableList>
</Stack>
</Column>
);
};
export default FollowRecommendations;

Wyświetl plik

@ -1,46 +0,0 @@
import React from 'react';
import Avatar from 'soapbox/components/avatar';
import DisplayName from 'soapbox/components/display-name';
import Permalink from 'soapbox/components/permalink';
import ActionButton from 'soapbox/features/ui/components/action-button';
import { useAppSelector } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors';
const getAccount = makeGetAccount();
const getFirstSentence = (str: string) => {
const arr = str.split(/(([.?!]+\s)|[.。?!\n•])/);
return arr[0];
};
interface IAccount {
id: string,
}
const Account: React.FC<IAccount> = ({ id }) => {
const account = useAppSelector((state) => getAccount(state, id));
if (!account) return null;
return (
<div className='account follow-recommendations-account'>
<div className='account__wrapper'>
<Permalink className='account__display-name account__display-name--with-note' title={account.acct} href={account.url} to={`/@${account.get('acct')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
<div className='account__note'>{getFirstSentence(account.get('note_plain'))}</div>
</Permalink>
<div className='account__relationship'>
<ActionButton account={account} />
</div>
</div>
</div>
);
};
export default Account;

Wyświetl plik

@ -1,30 +0,0 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Button } from 'soapbox/components/ui';
import FollowRecommendationsList from './follow_recommendations_list';
interface IFollowRecommendationsContainer {
onDone: () => void,
}
const FollowRecommendationsContainer: React.FC<IFollowRecommendationsContainer> = ({ onDone }) => (
<div className='scrollable follow-recommendations-container'>
<div className='column-title'>
<h3><FormattedMessage id='follow_recommendations.heading' defaultMessage="Follow people you'd like to see posts from! Here are some suggestions." /></h3>
<h2 className='follow_subhead'><FormattedMessage id='follow_recommendation.subhead' defaultMessage='Let&#39;s get started!' /></h2>
<p><FormattedMessage id='follow_recommendations.lead' defaultMessage='Don&#39;t be afraid to make mistakes; you can unfollow people at any time.' /></p>
</div>
<FollowRecommendationsList />
<div className='column-actions'>
<Button onClick={onDone}>
<FormattedMessage id='follow_recommendations.done' defaultMessage='Done' />
</Button>
</div>
</div>
);
export default FollowRecommendationsContainer;

Wyświetl plik

@ -1,44 +0,0 @@
import React, { useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
import { fetchSuggestions } from 'soapbox/actions/suggestions';
import { Spinner } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
import Account from './account';
const FollowRecommendationsList: React.FC = () => {
const dispatch = useDispatch();
const suggestions = useAppSelector((state) => state.suggestions.items);
const isLoading = useAppSelector((state) => state.suggestions.isLoading);
useEffect(() => {
if (suggestions.size === 0) {
dispatch(fetchSuggestions());
}
}, []);
if (isLoading) {
return (
<div className='column-list'>
<Spinner />
</div>
);
}
return (
<div className='column-list'>
{suggestions.size > 0 ? suggestions.map((suggestion) => (
<Account key={suggestion.account} id={suggestion.account} />
)) : (
<div className='column-list__empty-message'>
<FormattedMessage id='empty_column.follow_recommendations' defaultMessage='Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.' />
</div>
)}
</div>
);
};
export default FollowRecommendationsList;

Wyświetl plik

@ -1,22 +0,0 @@
import React from 'react';
import { useHistory } from 'react-router-dom';
import Column from 'soapbox/features/ui/components/column';
import FollowRecommendationsContainer from './components/follow_recommendations_container';
const FollowRecommendations: React.FC = () => {
const history = useHistory();
const onDone = () => {
history.push('/');
};
return (
<Column>
<FollowRecommendationsContainer onDone={onDone} />
</Column>
);
};
export default FollowRecommendations;

Wyświetl plik

@ -3,6 +3,7 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { expandHomeTimeline } from 'soapbox/actions/timelines';
import PullToRefresh from 'soapbox/components/pull-to-refresh';
import { Column, Stack, Text } from 'soapbox/components/ui';
import Timeline from 'soapbox/features/ui/components/timeline';
import { useAppSelector, useAppDispatch, useFeatures } from 'soapbox/hooks';
@ -59,47 +60,48 @@ const HomeTimeline: React.FC = () => {
return (
<Column label={intl.formatMessage(messages.title)} transparent>
<Timeline
scrollKey='home_timeline'
onLoadMore={handleLoadMore}
onRefresh={handleRefresh}
timelineId='home'
divideType='space'
emptyMessage={
<Stack space={1}>
<Text size='xl' weight='medium' align='center'>
<FormattedMessage
id='empty_column.home.title'
defaultMessage="You're not following anyone yet"
/>
</Text>
<Text theme='muted' align='center'>
<FormattedMessage
id='empty_column.home.subtitle'
defaultMessage='{siteTitle} gets more interesting once you follow other users.'
values={{ siteTitle }}
/>
</Text>
{features.federating && (
<Text theme='muted' align='center'>
<PullToRefresh onRefresh={handleRefresh}>
<Timeline
scrollKey='home_timeline'
onLoadMore={handleLoadMore}
timelineId='home'
divideType='space'
emptyMessage={
<Stack space={1}>
<Text size='xl' weight='medium' align='center'>
<FormattedMessage
id='empty_column.home'
defaultMessage='Or you can visit {public} to get started and meet other users.'
values={{
public: (
<Link to='/timeline/local' className='text-primary-600 dark:text-primary-400 hover:underline'>
<FormattedMessage id='empty_column.home.local_tab' defaultMessage='the {site_title} tab' values={{ site_title: siteTitle }} />
</Link>
),
}}
id='empty_column.home.title'
defaultMessage="You're not following anyone yet"
/>
</Text>
)}
</Stack>
}
/>
<Text theme='muted' align='center'>
<FormattedMessage
id='empty_column.home.subtitle'
defaultMessage='{siteTitle} gets more interesting once you follow other users.'
values={{ siteTitle }}
/>
</Text>
{features.federating && (
<Text theme='muted' align='center'>
<FormattedMessage
id='empty_column.home'
defaultMessage='Or you can visit {public} to get started and meet other users.'
values={{
public: (
<Link to='/timeline/local' className='text-primary-600 dark:text-primary-400 hover:underline'>
<FormattedMessage id='empty_column.home.local_tab' defaultMessage='the {site_title} tab' values={{ site_title: siteTitle }} />
</Link>
),
}}
/>
</Text>
)}
</Stack>
}
/>
</PullToRefresh>
</Column>
);
};

Wyświetl plik

@ -1,11 +1,9 @@
import * as React from 'react';
import { updateNotifications } from '../../../../actions/notifications';
import { render, screen, rootState, createTestStore } from '../../../../jest/test-helpers';
import { makeGetNotification } from '../../../../selectors';
import Notification from '../notification';
import { updateNotifications } from 'soapbox/actions/notifications';
import { render, screen, rootState, createTestStore } from 'soapbox/jest/test-helpers';
const getNotification = makeGetNotification();
import Notification from '../notification';
/** Prepare the notification for use by the component */
const normalize = (notification: any) => {
@ -15,7 +13,7 @@ const normalize = (notification: any) => {
return {
// @ts-ignore
notification: getNotification(state, state.notifications.items.get(notification.id)),
notification: state.notifications.items.get(notification.id),
state,
};
};

Wyświetl plik

@ -1,19 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Icon from 'soapbox/components/icon';
export default class ClearColumnButton extends React.PureComponent {
static propTypes = {
onClick: PropTypes.func.isRequired,
};
render() {
return (
<button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.props.onClick}><Icon src={require('@tabler/icons/eraser.svg')} /> <FormattedMessage id='notifications.clear' defaultMessage='Clear notifications' /></button>
);
}
}

Wyświetl plik

@ -0,0 +1,18 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Icon from 'soapbox/components/icon';
interface IClearColumnButton {
onClick: React.MouseEventHandler<HTMLButtonElement>;
}
const ClearColumnButton: React.FC<IClearColumnButton> = ({ onClick }) => (
<button className='text-btn column-header__setting-btn' tabIndex={0} onClick={onClick}>
<Icon src={require('@tabler/icons/eraser.svg')} />
{' '}
<FormattedMessage id='notifications.clear' defaultMessage='Clear notifications' />
</button>
);
export default ClearColumnButton;

Wyświetl plik

@ -1,106 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import Icon from 'soapbox/components/icon';
import { Tabs } from 'soapbox/components/ui';
const messages = defineMessages({
all: { id: 'notifications.filter.all', defaultMessage: 'All' },
mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Likes' },
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Reposts' },
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
moves: { id: 'notifications.filter.moves', defaultMessage: 'Moves' },
emoji_reacts: { id: 'notifications.filter.emoji_reacts', defaultMessage: 'Emoji reacts' },
statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' },
});
export default @injectIntl
class NotificationFilterBar extends React.PureComponent {
static propTypes = {
selectFilter: PropTypes.func.isRequired,
selectedFilter: PropTypes.string.isRequired,
advancedMode: PropTypes.bool.isRequired,
supportsEmojiReacts: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
onClick(notificationType) {
return () => this.props.selectFilter(notificationType);
}
render() {
const { selectedFilter, advancedMode, supportsEmojiReacts, intl } = this.props;
const items = [
{
text: intl.formatMessage(messages.all),
action: this.onClick('all'),
name: 'all',
},
];
if (!advancedMode) {
items.push({
text: intl.formatMessage(messages.mentions),
action: this.onClick('mention'),
name: 'mention',
});
} else {
items.push({
text: <Icon src={require('@tabler/icons/at.svg')} />,
title: intl.formatMessage(messages.mentions),
action: this.onClick('mention'),
name: 'mention',
});
items.push({
text: <Icon src={require('@tabler/icons/heart.svg')} />,
title: intl.formatMessage(messages.favourites),
action: this.onClick('favourite'),
name: 'favourite',
});
if (supportsEmojiReacts) items.push({
text: <Icon src={require('@tabler/icons/mood-smile.svg')} />,
title: intl.formatMessage(messages.emoji_reacts),
action: this.onClick('pleroma:emoji_reaction'),
name: 'pleroma:emoji_reaction',
});
items.push({
text: <Icon src={require('feather-icons/dist/icons/repeat.svg')} />,
title: intl.formatMessage(messages.boosts),
action: this.onClick('reblog'),
name: 'reblog',
});
items.push({
text: <Icon src={require('@tabler/icons/chart-bar.svg')} />,
title: intl.formatMessage(messages.polls),
action: this.onClick('poll'),
name: 'poll',
});
items.push({
text: <Icon src={require('@tabler/icons/bell-ringing.svg')} />,
title: intl.formatMessage(messages.statuses),
action: this.onClick('status'),
name: 'status',
});
items.push({
text: <Icon src={require('@tabler/icons/user-plus.svg')} />,
title: intl.formatMessage(messages.follows),
action: this.onClick('follow'),
name: 'follow',
});
items.push({
text: <Icon src={require('feather-icons/dist/icons/briefcase.svg')} />,
title: intl.formatMessage(messages.moves),
action: this.onClick('move'),
name: 'move',
});
}
return <Tabs items={items} activeItem={selectedFilter} />;
}
}

Wyświetl plik

@ -0,0 +1,102 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { setFilter } from 'soapbox/actions/notifications';
import Icon from 'soapbox/components/icon';
import { Tabs } from 'soapbox/components/ui';
import { useAppDispatch, useFeatures, useSettings } from 'soapbox/hooks';
import type { Item } from 'soapbox/components/ui/tabs/tabs';
const messages = defineMessages({
all: { id: 'notifications.filter.all', defaultMessage: 'All' },
mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Likes' },
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Reposts' },
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
moves: { id: 'notifications.filter.moves', defaultMessage: 'Moves' },
emoji_reacts: { id: 'notifications.filter.emoji_reacts', defaultMessage: 'Emoji reacts' },
statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' },
});
const NotificationFilterBar = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const settings = useSettings();
const features = useFeatures();
const selectedFilter = settings.getIn(['notifications', 'quickFilter', 'active']) as string;
const advancedMode = settings.getIn(['notifications', 'quickFilter', 'advanced']);
const onClick = (notificationType: string) => () => dispatch(setFilter(notificationType));
const items: Item[] = [
{
text: intl.formatMessage(messages.all),
action: onClick('all'),
name: 'all',
},
];
if (!advancedMode) {
items.push({
text: intl.formatMessage(messages.mentions),
action: onClick('mention'),
name: 'mention',
});
} else {
items.push({
text: <Icon src={require('@tabler/icons/at.svg')} />,
title: intl.formatMessage(messages.mentions),
action: onClick('mention'),
name: 'mention',
});
items.push({
text: <Icon src={require('@tabler/icons/heart.svg')} />,
title: intl.formatMessage(messages.favourites),
action: onClick('favourite'),
name: 'favourite',
});
if (features.emojiReacts) items.push({
text: <Icon src={require('@tabler/icons/mood-smile.svg')} />,
title: intl.formatMessage(messages.emoji_reacts),
action: onClick('pleroma:emoji_reaction'),
name: 'pleroma:emoji_reaction',
});
items.push({
text: <Icon src={require('feather-icons/dist/icons/repeat.svg')} />,
title: intl.formatMessage(messages.boosts),
action: onClick('reblog'),
name: 'reblog',
});
items.push({
text: <Icon src={require('@tabler/icons/chart-bar.svg')} />,
title: intl.formatMessage(messages.polls),
action: onClick('poll'),
name: 'poll',
});
items.push({
text: <Icon src={require('@tabler/icons/bell-ringing.svg')} />,
title: intl.formatMessage(messages.statuses),
action: onClick('status'),
name: 'status',
});
items.push({
text: <Icon src={require('@tabler/icons/user-plus.svg')} />,
title: intl.formatMessage(messages.follows),
action: onClick('follow'),
name: 'follow',
});
items.push({
text: <Icon src={require('feather-icons/dist/icons/briefcase.svg')} />,
title: intl.formatMessage(messages.moves),
action: onClick('move'),
name: 'move',
});
}
return <Tabs items={items} activeItem={selectedFilter} />;
};
export default NotificationFilterBar;

Wyświetl plik

@ -1,19 +1,27 @@
import React from 'react';
import React, { useCallback } from 'react';
import { HotKeys } from 'react-hotkeys';
import { defineMessages, useIntl, FormattedMessage, IntlShape, MessageDescriptor } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { mentionCompose } from 'soapbox/actions/compose';
import { reblog, favourite, unreblog, unfavourite } from 'soapbox/actions/interactions';
import { openModal } from 'soapbox/actions/modals';
import { getSettings } from 'soapbox/actions/settings';
import { hideStatus, revealStatus } from 'soapbox/actions/statuses';
import Icon from 'soapbox/components/icon';
import Permalink from 'soapbox/components/permalink';
import { HStack, Text, Emoji } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
import StatusContainer from 'soapbox/containers/status_container';
import { useAppSelector } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { makeGetNotification } from 'soapbox/selectors';
import { NotificationType, validType } from 'soapbox/utils/notification';
import type { ScrollPosition } from 'soapbox/components/status';
import type { Account, Status, Notification as NotificationEntity } from 'soapbox/types/entities';
const getNotification = makeGetNotification();
const notificationForScreenReader = (intl: IntlShape, message: string, timestamp: Date) => {
const output = [message];
@ -130,17 +138,17 @@ interface INotificaton {
notification: NotificationEntity,
onMoveUp?: (notificationId: string) => void,
onMoveDown?: (notificationId: string) => void,
onMention?: (account: Account) => void,
onFavourite?: (status: Status) => void,
onReblog?: (status: Status, e?: KeyboardEvent) => void,
onToggleHidden?: (status: Status) => void,
getScrollPosition?: () => ScrollPosition | undefined,
updateScrollBottom?: (bottom: number) => void,
siteTitle?: string,
}
const Notification: React.FC<INotificaton> = (props) => {
const { hidden = false, notification, onMoveUp, onMoveDown } = props;
const { hidden = false, onMoveUp, onMoveDown } = props;
const dispatch = useAppDispatch();
const notification = useAppSelector((state) => getNotification(state, props.notification));
const history = useHistory();
const intl = useIntl();
@ -175,31 +183,52 @@ const Notification: React.FC<INotificaton> = (props) => {
}
};
const handleMention = (e?: KeyboardEvent) => {
const handleMention = useCallback((e?: KeyboardEvent) => {
e?.preventDefault();
if (props.onMention && account && typeof account === 'object') {
props.onMention(account);
if (account && typeof account === 'object') {
dispatch(mentionCompose(account));
}
};
}, [account]);
const handleHotkeyFavourite = (e?: KeyboardEvent) => {
if (props.onFavourite && status && typeof status === 'object') {
props.onFavourite(status);
const handleHotkeyFavourite = useCallback((e?: KeyboardEvent) => {
if (status && typeof status === 'object') {
if (status.favourited) {
dispatch(unfavourite(status));
} else {
dispatch(favourite(status));
}
}
};
}, [status]);
const handleHotkeyBoost = (e?: KeyboardEvent) => {
if (props.onReblog && status && typeof status === 'object') {
props.onReblog(status, e);
const handleHotkeyBoost = useCallback((e?: KeyboardEvent) => {
if (status && typeof status === 'object') {
dispatch((_, getState) => {
const boostModal = getSettings(getState()).get('boostModal');
if (status.reblogged) {
dispatch(unreblog(status));
} else {
if (e?.shiftKey || !boostModal) {
dispatch(reblog(status));
} else {
dispatch(openModal('BOOST', { status, onReblog: (status: Status) => {
dispatch(reblog(status));
} }));
}
}
});
}
};
}, [status]);
const handleHotkeyToggleHidden = (e?: KeyboardEvent) => {
if (props.onToggleHidden && status && typeof status === 'object') {
props.onToggleHidden(status);
const handleHotkeyToggleHidden = useCallback((e?: KeyboardEvent) => {
if (status && typeof status === 'object') {
if (status.hidden) {
dispatch(revealStatus(status.id));
} else {
dispatch(hideStatus(status.id));
}
}
};
}, [status]);
const handleMoveUp = () => {
if (onMoveUp) {

Wyświetl plik

@ -1,27 +0,0 @@
import { connect } from 'react-redux';
import { setFilter } from 'soapbox/actions/notifications';
import { getSettings } from 'soapbox/actions/settings';
import { getFeatures } from 'soapbox/utils/features';
import FilterBar from '../components/filter_bar';
const makeMapStateToProps = state => {
const settings = getSettings(state);
const instance = state.get('instance');
const features = getFeatures(instance);
return {
selectedFilter: settings.getIn(['notifications', 'quickFilter', 'active']),
advancedMode: settings.getIn(['notifications', 'quickFilter', 'advanced']),
supportsEmojiReacts: features.emojiReacts,
};
};
const mapDispatchToProps = (dispatch) => ({
selectFilter(newActiveFilter) {
dispatch(setFilter(newActiveFilter));
},
});
export default connect(makeMapStateToProps, mapDispatchToProps)(FilterBar);

Wyświetl plik

@ -1,74 +0,0 @@
import { connect } from 'react-redux';
import { mentionCompose } from 'soapbox/actions/compose';
import {
reblog,
favourite,
unreblog,
unfavourite,
} from 'soapbox/actions/interactions';
import { openModal } from 'soapbox/actions/modals';
import { getSettings } from 'soapbox/actions/settings';
import {
hideStatus,
revealStatus,
} from 'soapbox/actions/statuses';
import { makeGetNotification } from 'soapbox/selectors';
import Notification from '../components/notification';
const makeMapStateToProps = () => {
const getNotification = makeGetNotification();
const mapStateToProps = (state, props) => {
return {
siteTitle: state.getIn(['instance', 'title']),
notification: getNotification(state, props.notification),
};
};
return mapStateToProps;
};
const mapDispatchToProps = dispatch => ({
onMention: (account) => {
dispatch(mentionCompose(account));
},
onModalReblog(status) {
dispatch(reblog(status));
},
onReblog(status, e) {
dispatch((_, getState) => {
const boostModal = getSettings(getState()).get('boostModal');
if (status.get('reblogged')) {
dispatch(unreblog(status));
} else {
if (e.shiftKey || !boostModal) {
this.onModalReblog(status);
} else {
dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
}
}
});
},
onFavourite(status) {
if (status.get('favourited')) {
dispatch(unfavourite(status));
} else {
dispatch(favourite(status));
}
},
onToggleHidden(status) {
if (status.get('hidden')) {
dispatch(revealStatus(status.get('id')));
} else {
dispatch(hideStatus(status.get('id')));
}
},
});
export default connect(makeMapStateToProps, mapDispatchToProps)(Notification);

Wyświetl plik

@ -1,208 +0,0 @@
import classNames from 'classnames';
import { List as ImmutableList } from 'immutable';
import debounce from 'lodash/debounce';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import {
expandNotifications,
scrollTopNotifications,
dequeueNotifications,
} from 'soapbox/actions/notifications';
import { getSettings } from 'soapbox/actions/settings';
import ScrollTopButton from 'soapbox/components/scroll-top-button';
import ScrollableList from 'soapbox/components/scrollable_list';
import { Column } from 'soapbox/components/ui';
import PlaceholderNotification from 'soapbox/features/placeholder/components/placeholder_notification';
import FilterBarContainer from './containers/filter_bar_container';
import NotificationContainer from './containers/notification_container';
const messages = defineMessages({
title: { id: 'column.notifications', defaultMessage: 'Notifications' },
queue: { id: 'notifications.queue_label', defaultMessage: 'Click to see {count} new {count, plural, one {notification} other {notifications}}' },
});
const getNotifications = createSelector([
state => getSettings(state).getIn(['notifications', 'quickFilter', 'show']),
state => getSettings(state).getIn(['notifications', 'quickFilter', 'active']),
state => ImmutableList(getSettings(state).getIn(['notifications', 'shows']).filter(item => !item).keys()),
state => state.getIn(['notifications', 'items']).toList(),
], (showFilterBar, allowedType, excludedTypes, notifications) => {
if (!showFilterBar || allowedType === 'all') {
// used if user changed the notification settings after loading the notifications from the server
// otherwise a list of notifications will come pre-filtered from the backend
// we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category
return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type')));
}
return notifications.filter(item => item !== null && allowedType === item.get('type'));
});
const mapStateToProps = state => {
const settings = getSettings(state);
return {
showFilterBar: settings.getIn(['notifications', 'quickFilter', 'show']),
notifications: getNotifications(state),
isLoading: state.getIn(['notifications', 'isLoading'], true),
isUnread: state.getIn(['notifications', 'unread']) > 0,
hasMore: state.getIn(['notifications', 'hasMore']),
totalQueuedNotificationsCount: state.getIn(['notifications', 'totalQueuedNotificationsCount'], 0),
};
};
export default @connect(mapStateToProps)
@injectIntl
class Notifications extends React.PureComponent {
static propTypes = {
notifications: ImmutablePropTypes.list.isRequired,
showFilterBar: PropTypes.bool.isRequired,
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
isLoading: PropTypes.bool,
isUnread: PropTypes.bool,
hasMore: PropTypes.bool,
dequeueNotifications: PropTypes.func,
totalQueuedNotificationsCount: PropTypes.number,
};
componentWillUnmount() {
this.handleLoadOlder.cancel();
this.handleScrollToTop.cancel();
this.handleScroll.cancel();
this.props.dispatch(scrollTopNotifications(false));
}
componentDidMount() {
this.handleDequeueNotifications();
this.props.dispatch(scrollTopNotifications(true));
}
handleLoadGap = (maxId) => {
this.props.dispatch(expandNotifications({ maxId }));
};
handleLoadOlder = debounce(() => {
const last = this.props.notifications.last();
this.props.dispatch(expandNotifications({ maxId: last && last.get('id') }));
}, 300, { leading: true });
handleScrollToTop = debounce(() => {
this.props.dispatch(scrollTopNotifications(true));
}, 100);
handleScroll = debounce(() => {
this.props.dispatch(scrollTopNotifications(false));
}, 100);
setRef = c => {
this.node = c;
}
setColumnRef = c => {
this.column = c;
}
handleMoveUp = id => {
const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
this._selectChild(elementIndex);
}
handleMoveDown = id => {
const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
this._selectChild(elementIndex);
}
_selectChild(index) {
this.node.scrollIntoView({
index,
behavior: 'smooth',
done: () => {
const container = this.column;
const element = container.querySelector(`[data-index="${index}"] .focusable`);
if (element) {
element.focus();
}
},
});
}
handleDequeueNotifications = () => {
this.props.dispatch(dequeueNotifications());
};
handleRefresh = () => {
const { dispatch } = this.props;
return dispatch(expandNotifications());
}
render() {
const { intl, notifications, isLoading, hasMore, showFilterBar, totalQueuedNotificationsCount } = this.props;
const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />;
let scrollableContent = null;
const filterBarContainer = showFilterBar
? (<FilterBarContainer />)
: null;
if (isLoading && this.scrollableContent) {
scrollableContent = this.scrollableContent;
} else if (notifications.size > 0 || hasMore) {
scrollableContent = notifications.map((item, index) => (
<NotificationContainer
key={item.get('id')}
notification={item}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
/>
));
} else {
scrollableContent = null;
}
this.scrollableContent = scrollableContent;
const scrollContainer = (
<ScrollableList
ref={this.setRef}
scrollKey='notifications'
isLoading={isLoading}
showLoading={isLoading && notifications.size === 0}
hasMore={hasMore}
emptyMessage={emptyMessage}
placeholderComponent={PlaceholderNotification}
placeholderCount={20}
onLoadMore={this.handleLoadOlder}
onRefresh={this.handleRefresh}
onScrollToTop={this.handleScrollToTop}
onScroll={this.handleScroll}
className={classNames({
'divide-y divide-gray-200 dark:divide-gray-600 divide-solid': notifications.size > 0,
'space-y-2': notifications.size === 0,
})}
>
{scrollableContent}
</ScrollableList>
);
return (
<Column ref={this.setColumnRef} label={intl.formatMessage(messages.title)} withHeader={false}>
{filterBarContainer}
<ScrollTopButton
onClick={this.handleDequeueNotifications}
count={totalQueuedNotificationsCount}
message={messages.queue}
/>
{scrollContainer}
</Column>
);
}
}

Wyświetl plik

@ -0,0 +1,191 @@
import classNames from 'classnames';
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import debounce from 'lodash/debounce';
import React, { useCallback, useEffect, useRef } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { createSelector } from 'reselect';
import {
expandNotifications,
scrollTopNotifications,
dequeueNotifications,
} from 'soapbox/actions/notifications';
import { getSettings } from 'soapbox/actions/settings';
import PullToRefresh from 'soapbox/components/pull-to-refresh';
import ScrollTopButton from 'soapbox/components/scroll-top-button';
import ScrollableList from 'soapbox/components/scrollable_list';
import { Column } from 'soapbox/components/ui';
import PlaceholderNotification from 'soapbox/features/placeholder/components/placeholder_notification';
import { useAppDispatch, useAppSelector, useSettings } from 'soapbox/hooks';
import FilterBar from './components/filter_bar';
import Notification from './components/notification';
import type { VirtuosoHandle } from 'react-virtuoso';
import type { RootState } from 'soapbox/store';
import type { Notification as NotificationEntity } from 'soapbox/types/entities';
const messages = defineMessages({
title: { id: 'column.notifications', defaultMessage: 'Notifications' },
queue: { id: 'notifications.queue_label', defaultMessage: 'Click to see {count} new {count, plural, one {notification} other {notifications}}' },
});
const getNotifications = createSelector([
state => getSettings(state).getIn(['notifications', 'quickFilter', 'show']),
state => getSettings(state).getIn(['notifications', 'quickFilter', 'active']),
state => ImmutableList((getSettings(state).getIn(['notifications', 'shows']) as ImmutableMap<string, boolean>).filter(item => !item).keys()),
(state: RootState) => state.notifications.items.toList(),
], (showFilterBar, allowedType, excludedTypes, notifications: ImmutableList<NotificationEntity>) => {
if (!showFilterBar || allowedType === 'all') {
// used if user changed the notification settings after loading the notifications from the server
// otherwise a list of notifications will come pre-filtered from the backend
// we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category
return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type')));
}
return notifications.filter(item => item !== null && allowedType === item.get('type'));
});
const Notifications = () => {
const dispatch = useAppDispatch();
const intl = useIntl();
const settings = useSettings();
const showFilterBar = settings.getIn(['notifications', 'quickFilter', 'show']);
const activeFilter = settings.getIn(['notifications', 'quickFilter', 'active']);
const notifications = useAppSelector(state => getNotifications(state));
const isLoading = useAppSelector(state => state.notifications.isLoading);
// const isUnread = useAppSelector(state => state.notifications.unread > 0);
const hasMore = useAppSelector(state => state.notifications.hasMore);
const totalQueuedNotificationsCount = useAppSelector(state => state.notifications.totalQueuedNotificationsCount || 0);
const node = useRef<VirtuosoHandle>(null);
const column = useRef<HTMLDivElement>(null);
const scrollableContentRef = useRef<ImmutableList<JSX.Element> | null>(null);
// const handleLoadGap = (maxId) => {
// dispatch(expandNotifications({ maxId }));
// };
const handleLoadOlder = useCallback(debounce(() => {
const last = notifications.last();
dispatch(expandNotifications({ maxId: last && last.get('id') }));
}, 300, { leading: true }), []);
const handleScrollToTop = useCallback(debounce(() => {
dispatch(scrollTopNotifications(true));
}, 100), []);
const handleScroll = useCallback(debounce(() => {
dispatch(scrollTopNotifications(false));
}, 100), []);
const handleMoveUp = (id: string) => {
const elementIndex = notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
_selectChild(elementIndex);
};
const handleMoveDown = (id: string) => {
const elementIndex = notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
_selectChild(elementIndex);
};
const _selectChild = (index: number) => {
node.current?.scrollIntoView({
index,
behavior: 'smooth',
done: () => {
const container = column.current;
const element = container?.querySelector(`[data-index="${index}"] .focusable`);
if (element) {
(element as HTMLDivElement).focus();
}
},
});
};
const handleDequeueNotifications = () => {
dispatch(dequeueNotifications());
};
const handleRefresh = () => {
return dispatch(expandNotifications());
};
useEffect(() => {
handleDequeueNotifications();
dispatch(scrollTopNotifications(true));
return () => {
handleLoadOlder.cancel();
handleScrollToTop.cancel();
handleScroll.cancel();
dispatch(scrollTopNotifications(false));
};
}, []);
const emptyMessage = activeFilter === 'all'
? <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />
: <FormattedMessage id='empty_column.notifications_filtered' defaultMessage="You don't have any notifications of this type yet." />;
let scrollableContent: ImmutableList<JSX.Element> | null = null;
const filterBarContainer = showFilterBar
? (<FilterBar />)
: null;
if (isLoading && scrollableContentRef.current) {
scrollableContent = scrollableContentRef.current;
} else if (notifications.size > 0 || hasMore) {
scrollableContent = notifications.map((item) => (
<Notification
key={item.id}
notification={item}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
/>
));
} else {
scrollableContent = null;
}
scrollableContentRef.current = scrollableContent;
const scrollContainer = (
<ScrollableList
ref={node}
scrollKey='notifications'
isLoading={isLoading}
showLoading={isLoading && notifications.size === 0}
hasMore={hasMore}
emptyMessage={emptyMessage}
placeholderComponent={PlaceholderNotification}
placeholderCount={20}
onLoadMore={handleLoadOlder}
onScrollToTop={handleScrollToTop}
onScroll={handleScroll}
className={classNames({
'divide-y divide-gray-200 dark:divide-gray-600 divide-solid': notifications.size > 0,
'space-y-2': notifications.size === 0,
})}
>
{scrollableContent as ImmutableList<JSX.Element>}
</ScrollableList>
);
return (
<Column ref={column} label={intl.formatMessage(messages.title)} withHeader={false}>
{filterBarContainer}
<ScrollTopButton
onClick={handleDequeueNotifications}
count={totalQueuedNotificationsCount}
message={messages.queue}
/>
<PullToRefresh onRefresh={handleRefresh}>
{scrollContainer}
</PullToRefresh>
</Column>
);
};
export default Notifications;

Wyświetl plik

@ -45,6 +45,7 @@ const SuggestedAccountsStep = ({ onNext }: { onNext: () => void }) => {
// @ts-ignore: TS thinks `id` is passed to <Account>, but it isn't
id={suggestion.account}
showProfileHoverCard={false}
withLinkToProfile={false}
/>
</div>
))}

Wyświetl plik

@ -1,66 +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, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { fetchPinnedStatuses } from 'soapbox/actions/pin_statuses';
import MissingIndicator from 'soapbox/components/missing_indicator';
import StatusList from 'soapbox/components/status_list';
import Column from '../ui/components/column';
const messages = defineMessages({
heading: { id: 'column.pins', defaultMessage: 'Pinned posts' },
});
const mapStateToProps = (state, { params }) => {
const username = params.username || '';
const me = state.get('me');
const meUsername = state.getIn(['accounts', me, 'username'], '');
return {
isMyAccount: (username.toLowerCase() === meUsername.toLowerCase()),
statusIds: state.status_lists.get('pins').items,
hasMore: !!state.status_lists.get('pins').next,
};
};
export default @connect(mapStateToProps)
@injectIntl
class PinnedStatuses extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.orderedSet.isRequired,
intl: PropTypes.object.isRequired,
hasMore: PropTypes.bool.isRequired,
isMyAccount: PropTypes.bool.isRequired,
};
componentDidMount() {
this.props.dispatch(fetchPinnedStatuses());
}
render() {
const { intl, statusIds, hasMore, isMyAccount } = this.props;
if (!isMyAccount) {
return (
<MissingIndicator />
);
}
return (
<Column label={intl.formatMessage(messages.heading)}>
<StatusList
statusIds={statusIds}
scrollKey='pinned_statuses'
hasMore={hasMore}
emptyMessage={<FormattedMessage id='pinned_statuses.none' defaultMessage='No pins to show.' />}
/>
</Column>
);
}
}

Wyświetl plik

@ -0,0 +1,51 @@
import React, { useEffect } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useParams } from 'react-router-dom';
import { fetchPinnedStatuses } from 'soapbox/actions/pin_statuses';
import MissingIndicator from 'soapbox/components/missing_indicator';
import StatusList from 'soapbox/components/status_list';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import Column from '../ui/components/column';
const messages = defineMessages({
heading: { id: 'column.pins', defaultMessage: 'Pinned posts' },
});
const PinnedStatuses = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { username } = useParams<{ username: string }>();
const meUsername = useAppSelector((state) => state.accounts.get(state.me)?.username || '');
const statusIds = useAppSelector((state) => state.status_lists.get('pins')!.items);
const isLoading = useAppSelector((state) => !!state.status_lists.get('pins')!.isLoading);
const hasMore = useAppSelector((state) => !!state.status_lists.get('pins')!.next);
const isMyAccount = username.toLowerCase() === meUsername.toLowerCase();
useEffect(() => {
dispatch(fetchPinnedStatuses());
}, []);
if (!isMyAccount) {
return (
<MissingIndicator />
);
}
return (
<Column label={intl.formatMessage(messages.heading)}>
<StatusList
statusIds={statusIds}
scrollKey='pinned_statuses'
hasMore={hasMore}
isLoading={isLoading}
emptyMessage={<FormattedMessage id='pinned_statuses.none' defaultMessage='No pins to show.' />}
/>
</Column>
);
};
export default PinnedStatuses;

Wyświetl plik

@ -2,13 +2,12 @@ import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { getSettings, changeSetting } from 'soapbox/actions/settings';
import { changeSetting } from 'soapbox/actions/settings';
import List, { ListItem } from 'soapbox/components/list';
import { Form } from 'soapbox/components/ui';
import { SelectDropdown } from 'soapbox/features/forms';
import SettingToggle from 'soapbox/features/notifications/components/setting_toggle';
import { useAppSelector } from 'soapbox/hooks';
import { getFeatures } from 'soapbox/utils/features';
import { useFeatures, useSettings } from 'soapbox/hooks';
import ThemeToggle from '../ui/components/theme-toggle';
@ -91,9 +90,8 @@ const messages = defineMessages({
const Preferences = () => {
const intl = useIntl();
const dispatch = useDispatch();
const features = useAppSelector((state) => getFeatures(state.instance));
const settings = useAppSelector((state) => getSettings(state));
const features = useFeatures();
const settings = useSettings();
const onSelectChange = (event: React.ChangeEvent<HTMLSelectElement>, path: string[]) => {
dispatch(changeSetting(path, event.target.value, { showAlert: true }));
@ -159,75 +157,28 @@ const Preferences = () => {
/>
</ListItem>
{features.privacyScopes && <ListItem label={<FormattedMessage id='preferences.fields.privacy_label' defaultMessage='Default post privacy' />}>
<SelectDropdown
items={defaultPrivacyOptions}
defaultValue={settings.get('defaultPrivacy') as string | undefined}
onChange={(event: React.ChangeEvent<HTMLSelectElement>) => onSelectChange(event, ['defaultPrivacy'])}
/>
</ListItem>}
{features.privacyScopes && (
<ListItem label={<FormattedMessage id='preferences.fields.privacy_label' defaultMessage='Default post privacy' />}>
<SelectDropdown
items={defaultPrivacyOptions}
defaultValue={settings.get('defaultPrivacy') as string | undefined}
onChange={(event: React.ChangeEvent<HTMLSelectElement>) => onSelectChange(event, ['defaultPrivacy'])}
/>
</ListItem>
)}
{features.richText && <ListItem label={<FormattedMessage id='preferences.fields.content_type_label' defaultMessage='Default post format' />}>
<SelectDropdown
items={defaultContentTypeOptions}
defaultValue={settings.get('defaultContentType') as string | undefined}
onChange={(event: React.ChangeEvent<HTMLSelectElement>) => onSelectChange(event, ['defaultContentType'])}
/>
</ListItem>}
{features.richText && (
<ListItem label={<FormattedMessage id='preferences.fields.content_type_label' defaultMessage='Default post format' />}>
<SelectDropdown
items={defaultContentTypeOptions}
defaultValue={settings.get('defaultContentType') as string | undefined}
onChange={(event: React.ChangeEvent<HTMLSelectElement>) => onSelectChange(event, ['defaultContentType'])}
/>
</ListItem>
)}
</List>
{/* <FieldsGroup>
<RadioGroup
label={<FormattedMessage id='preferences.fields.privacy_label' defaultMessage='Post privacy' />}
onChange={this.onDefaultPrivacyChange}
>
<RadioItem
label={<FormattedMessage id='preferences.options.privacy_public' defaultMessage='Public' />}
hint={<FormattedMessage id='preferences.hints.privacy_public' defaultMessage='Everyone can see' />}
checked={settings.get('defaultPrivacy') === 'public'}
value='public'
/>
<RadioItem
label={<FormattedMessage id='preferences.options.privacy_unlisted' defaultMessage='Unlisted' />}
hint={<FormattedMessage id='preferences.hints.privacy_unlisted' defaultMessage='Everyone can see, but not listed on public timelines' />}
checked={settings.get('defaultPrivacy') === 'unlisted'}
value='unlisted'
/>
<RadioItem
label={<FormattedMessage id='preferences.options.privacy_followers_only' defaultMessage='Followers-only' />}
hint={<FormattedMessage id='preferences.hints.privacy_followers_only' defaultMessage='Only show to followers' />}
checked={settings.get('defaultPrivacy') === 'private'}
value='private'
/>
</RadioGroup>
</FieldsGroup> */}
{/* {features.richText && (
<FieldsGroup>
<RadioGroup
label={<FormattedMessage id='preferences.fields.content_type_label' defaultMessage='Post format' />}
onChange={onDefaultContentTypeChange}
>
<RadioItem
label={<FormattedMessage id='preferences.options.content_type_plaintext' defaultMessage='Plain text' />}
checked={settings.get('defaultContentType') === 'text/plain'}
value='text/plain'
/>
<RadioItem
label={<FormattedMessage id='preferences.options.content_type_markdown' defaultMessage='Markdown' />}
hint={<FormattedMessage id='preferences.hints.content_type_markdown' defaultMessage='Warning: experimental!' />}
checked={settings.get('defaultContentType') === 'text/markdown'}
value='text/markdown'
/>
</RadioGroup>
</FieldsGroup>
)} */}
<List>
{/* <ListItem label={<FormattedMessage id='preferences.fields.unfollow_modal_label' defaultMessage='Show confirmation dialog before unfollowing someone' />}>
<SettingToggle settings={settings} settingPath={['unfollowModal']} onChange={onToggleChange} />
</ListItem> */}
<ListItem label={<FormattedMessage id='preferences.fields.boost_modal_label' defaultMessage='Show confirmation dialog before reposting' />}>
<SettingToggle settings={settings} settingPath={['boostModal']} onChange={onToggleChange} />
</ListItem>
@ -235,10 +186,6 @@ const Preferences = () => {
<ListItem label={<FormattedMessage id='preferences.fields.delete_modal_label' defaultMessage='Show confirmation dialog before deleting a post' />}>
<SettingToggle settings={settings} settingPath={['deleteModal']} onChange={onToggleChange} />
</ListItem>
{/* <ListItem label={<FormattedMessage id='preferences.fields.missing_description_modal_label' defaultMessage='Show confirmation dialog before sending a post without media descriptions' />}>
<SettingToggle settings={settings} settingPath={['missingDescriptionModal']} onChange={onToggleChange} />
</ListItem> */}
</List>
<List>
@ -250,10 +197,6 @@ const Preferences = () => {
<SettingToggle settings={settings} settingPath={['expandSpoilers']} onChange={onToggleChange} />
</ListItem>}
{/* <ListItem label={<FormattedMessage id='preferences.fields.reduce_motion_label' defaultMessage='Reduce motion in animations' />}>
<SettingToggle settings={settings} settingPath={['reduceMotion']} onChange={onToggleChange} />
</ListItem> */}
<ListItem label={<FormattedMessage id='preferences.fields.autoload_timelines_label' defaultMessage='Automatically load new posts when scrolled to the top of the page' />}>
<SettingToggle settings={settings} settingPath={['autoloadTimelines']} onChange={onToggleChange} />
</ListItem>
@ -261,32 +204,6 @@ const Preferences = () => {
<ListItem label={<FormattedMessage id='preferences.fields.autoload_more_label' defaultMessage='Automatically load more items when scrolled to the bottom of the page' />}>
<SettingToggle settings={settings} settingPath={['autoloadMore']} onChange={onToggleChange} />
</ListItem>
{/* <ListItem label={<FormattedMessage id='preferences.fields.underline_links_label' defaultMessage='Always underline links in posts' />}>
<SettingToggle settings={settings} settingPath={['underlineLinks']} onChange={onToggleChange} />
</ListItem> */}
{/* <ListItem label={<FormattedMessage id='preferences.fields.system_font_label' defaultMessage="Use system's default font" />}>
<SettingToggle settings={settings} settingPath={['systemFont']} onChange={onToggleChange} />
</ListItem> */}
{/* <div className='dyslexic'>
<SettingsCheckbox
label={<FormattedMessage id='preferences.fields.dyslexic_font_label' defaultMessage='Dyslexic mode' />}
path={['dyslexicFont']}
/>
</div> */}
{/* <SettingsCheckbox
label={<FormattedMessage id='preferences.fields.halloween_label' defaultMessage='Halloween mode' />}
hint={<FormattedMessage id='preferences.hints.halloween' defaultMessage='Beware: SPOOKY! Supports light/dark toggle.' />}
path={['halloween']}
/> */}
{/* <ListItem
label={<FormattedMessage id='preferences.fields.demetricator_label' defaultMessage='Use Demetricator' />}
hint={<FormattedMessage id='preferences.hints.demetricator' defaultMessage='Decrease social media anxiety by hiding all numbers from the site.' />}
>
<SettingToggle settings={settings} settingPath={['demetricator']} onChange={onToggleChange} />
</ListItem> */}
</List>
</Form>
);

Wyświetl plik

@ -1,63 +0,0 @@
import { List as ImmutableList } from 'immutable';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { getSettings } from 'soapbox/actions/settings';
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import { Text } from 'soapbox/components/ui';
const mapStateToProps = (state, props) => {
const soapboxConfig = getSoapboxConfig(state);
return {
copyright: soapboxConfig.get('copyright'),
navlinks: soapboxConfig.getIn(['navlinks', 'homeFooter'], ImmutableList()),
locale: getSettings(state).get('locale'),
};
};
export default @connect(mapStateToProps)
class Footer extends ImmutablePureComponent {
static propTypes = {
copyright: PropTypes.string,
locale: PropTypes.string,
navlinks: ImmutablePropTypes.list,
}
render() {
const { copyright, locale, navlinks } = this.props;
return (
<footer className='relative max-w-7xl mt-auto mx-auto py-12 px-4 sm:px-6 xl:flex xl:items-center xl:justify-between lg:px-8'>
<div className='flex flex-wrap justify-center'>
{navlinks.map((link, idx) => {
const url = link.get('url');
const isExternal = url.startsWith('http');
const Comp = isExternal ? 'a' : Link;
const compProps = isExternal ? { href: url, target: '_blank' } : { to: url };
return (
<div key={idx} className='px-5 py-2'>
<Comp {...compProps} className='hover:underline'>
<Text tag='span' theme='primary' size='sm'>
{link.getIn(['titleLocales', locale]) || link.get('title')}
</Text>
</Comp>
</div>
);
})}
</div>
<div className='mt-6 xl:mt-0'>
<Text theme='muted' align='center' size='sm'>{copyright}</Text>
</div>
</footer>
);
}
}

Wyświetl plik

@ -0,0 +1,51 @@
import { List as ImmutableList } from 'immutable';
import React from 'react';
import { Link } from 'react-router-dom';
import { getSettings } from 'soapbox/actions/settings';
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import { Text } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
import type { FooterItem } from 'soapbox/types/soapbox';
const Footer = () => {
const { copyright, navlinks, locale } = useAppSelector((state) => {
const soapboxConfig = getSoapboxConfig(state);
return {
copyright: soapboxConfig.copyright,
navlinks: (soapboxConfig.navlinks.get('homeFooter') || ImmutableList()) as ImmutableList<FooterItem>,
locale: getSettings(state).get('locale') as string,
};
});
return (
<footer className='relative max-w-7xl mt-auto mx-auto py-12 px-4 sm:px-6 xl:flex xl:items-center xl:justify-between lg:px-8'>
<div className='flex flex-wrap justify-center'>
{navlinks.map((link, idx) => {
const url = link.get('url');
const isExternal = url.startsWith('http');
const Comp = (isExternal ? 'a' : Link) as 'a';
const compProps = isExternal ? { href: url, target: '_blank' } : { to: url };
return (
<div key={idx} className='px-5 py-2'>
<Comp {...compProps} className='hover:underline'>
<Text tag='span' theme='primary' size='sm'>
{(link.getIn(['titleLocales', locale]) || link.get('title')) as string}
</Text>
</Comp>
</div>
);
})}
</div>
<div className='mt-6 xl:mt-0'>
<Text theme='muted' align='center' size='sm'>{copyright}</Text>
</div>
</footer>
);
};
export default Footer;

Wyświetl plik

@ -5,6 +5,7 @@ import { Link } from 'react-router-dom';
import { changeSetting } from 'soapbox/actions/settings';
import { connectPublicStream } from 'soapbox/actions/streaming';
import { expandPublicTimeline } 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 Accordion from 'soapbox/features/ui/components/accordion';
@ -91,14 +92,15 @@ const CommunityTimeline = () => {
/>
</Accordion>
</div>}
<Timeline
scrollKey={`${timelineId}_timeline`}
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}`}
onLoadMore={handleLoadMore}
onRefresh={handleRefresh}
emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other servers to fill it up' />}
divideType='space'
/>
<PullToRefresh onRefresh={handleRefresh}>
<Timeline
scrollKey={`${timelineId}_timeline`}
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}`}
onLoadMore={handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other servers to fill it up' />}
divideType='space'
/>
</PullToRefresh>
</Column>
);
};

Wyświetl plik

@ -7,7 +7,7 @@ import snackbar from 'soapbox/actions/snackbar';
import { Button, Form, FormGroup, Input, FormActions, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
const messages = defineMessages({
const messages = defineMessages({
mfa_setup_disable_button: { id: 'column.mfa_disable_button', defaultMessage: 'Disable' },
disableFail: { id: 'security.disable.fail', defaultMessage: 'Incorrect password. Try again.' },
mfaDisableSuccess: { id: 'mfa.disable.success_message', defaultMessage: 'MFA disabled' },

Wyświetl plik

@ -7,7 +7,7 @@ import snackbar from 'soapbox/actions/snackbar';
import { Button, FormActions, Spinner, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
const messages = defineMessages({
const messages = defineMessages({
mfaCancelButton: { id: 'column.mfa_cancel', defaultMessage: 'Cancel' },
mfaSetupButton: { id: 'column.mfa_setup', defaultMessage: 'Proceed to Setup' },
codesFail: { id: 'security.codes.fail', defaultMessage: 'Failed to fetch backup codes' },

Wyświetl plik

@ -49,6 +49,7 @@ import {
fetchNext,
} from 'soapbox/actions/statuses';
import MissingIndicator from 'soapbox/components/missing_indicator';
import PullToRefresh from 'soapbox/components/pull-to-refresh';
import ScrollableList from 'soapbox/components/scrollable_list';
import { textForScreenReader } from 'soapbox/components/status';
import SubNavigation from 'soapbox/components/sub_navigation';
@ -796,23 +797,24 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
<SubNavigation message={intl.formatMessage(titleMessage, { username })} />
</div>
<Stack space={2}>
<div ref={this.setRef} className='thread'>
<ScrollableList
id='thread'
ref={this.setScrollerRef}
onRefresh={this.handleRefresh}
hasMore={!!this.state.next}
onLoadMore={this.handleLoadMore}
placeholderComponent={() => <PlaceholderStatus thread />}
initialTopMostItemIndex={ancestorsIds.size}
>
{children}
</ScrollableList>
</div>
<PullToRefresh onRefresh={this.handleRefresh}>
<Stack space={2}>
<div ref={this.setRef} className='thread'>
<ScrollableList
id='thread'
ref={this.setScrollerRef}
hasMore={!!this.state.next}
onLoadMore={this.handleLoadMore}
placeholderComponent={() => <PlaceholderStatus thread />}
initialTopMostItemIndex={ancestorsIds.size}
>
{children}
</ScrollableList>
</div>
{!me && <ThreadLoginCta />}
</Stack>
{!me && <ThreadLoginCta />}
</Stack>
</PullToRefresh>
</Column>
);
}

Wyświetl plik

@ -40,7 +40,7 @@ const ActionsModal: React.FC<IActionsModal> = ({ status, actions, onClick, onClo
className={classNames({ active, destructive })}
data-method={isLogout ? 'delete' : null}
>
{icon && <Icon title={text} src={icon} role='presentation' tabIndex='-1' inverted />}
{icon && <Icon title={text} src={icon} role='presentation' tabIndex={-1} />}
<div>
<div className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div>
<div>{meta}</div>
@ -60,6 +60,7 @@ const ActionsModal: React.FC<IActionsModal> = ({ status, actions, onClick, onClo
key={status.account as string}
id={status.account as string}
showProfileHoverCard={false}
withLinkToProfile={false}
timestamp={status.created_at}
/>
<StatusContent status={status} />

Wyświetl plik

@ -1,53 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import IconButton from '../../../components/icon_button';
const messages = defineMessages({
error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this page.' },
retry: { id: 'bundle_modal_error.retry', defaultMessage: 'Try again' },
close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' },
});
class BundleModalError extends React.PureComponent {
static propTypes = {
onRetry: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
}
handleRetry = () => {
this.props.onRetry();
}
render() {
const { onClose, intl: { formatMessage } } = this.props;
// Keep the markup in sync with <ModalLoading />
// (make sure they have the same dimensions)
return (
<div className='modal-root__modal error-modal'>
<div className='error-modal__body'>
<IconButton title={formatMessage(messages.retry)} icon='refresh' onClick={this.handleRetry} size={64} />
{formatMessage(messages.error)}
</div>
<div className='error-modal__footer'>
<div>
<button
onClick={onClose}
className='error-modal__nav onboarding-modal__skip'
>
{formatMessage(messages.close)}
</button>
</div>
</div>
</div>
);
}
}
export default injectIntl(BundleModalError);

Wyświetl plik

@ -0,0 +1,45 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import IconButton from 'soapbox/components/icon_button';
const messages = defineMessages({
error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this page.' },
retry: { id: 'bundle_modal_error.retry', defaultMessage: 'Try again' },
close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' },
});
interface IBundleModalError {
onRetry: () => void,
onClose: () => void,
}
const BundleModalError: React.FC<IBundleModalError> = ({ onRetry, onClose }) => {
const intl = useIntl();
const handleRetry = () => {
onRetry();
};
return (
<div className='modal-root__modal error-modal'>
<div className='error-modal__body'>
<IconButton title={intl.formatMessage(messages.retry)} icon='refresh' onClick={handleRetry} size={64} />
{intl.formatMessage(messages.error)}
</div>
<div className='error-modal__footer'>
<div>
<button
onClick={onClose}
className='error-modal__nav onboarding-modal__skip'
>
{intl.formatMessage(messages.close)}
</button>
</div>
</div>
</div>
);
};
export default BundleModalError;

Wyświetl plik

@ -49,6 +49,7 @@ const SelectedStatus = ({ statusId }: { statusId: string }) => {
<AccountContainer
id={status.account as any}
showProfileHoverCard={false}
withLinkToProfile={false}
timestamp={status.created_at}
hideActions
/>

Wyświetl plik

@ -6,11 +6,9 @@ import { verifyCredentials } from 'soapbox/actions/auth';
import { closeModal } from 'soapbox/actions/modals';
import snackbar from 'soapbox/actions/snackbar';
import { reConfirmPhoneVerification, reRequestPhoneVerification } from 'soapbox/actions/verification';
import { FormGroup, Input, Modal, Stack, Text } from 'soapbox/components/ui';
import { validPhoneNumberRegex } from 'soapbox/features/verification/steps/sms-verification';
import { FormGroup, PhoneInput, Modal, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { getAccessToken } from 'soapbox/utils/auth';
import { formatPhoneNumber } from 'soapbox/utils/phone';
interface IVerifySmsModal {
onClose: (type: string) => void,
@ -32,16 +30,14 @@ const VerifySmsModal: React.FC<IVerifySmsModal> = ({ onClose }) => {
const isLoading = useAppSelector((state) => state.verification.isLoading);
const [status, setStatus] = useState<Statuses>(Statuses.IDLE);
const [phone, setPhone] = useState<string>('');
const [phone, setPhone] = useState<string>();
const [verificationCode, setVerificationCode] = useState('');
const [requestedAnother, setAlreadyRequestedAnother] = useState(false);
const isValid = validPhoneNumberRegex.test(phone);
const isValid = !!phone;
const onChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const formattedPhone = formatPhoneNumber(event.target.value);
setPhone(formattedPhone);
const onChange = useCallback((phone?: string) => {
setPhone(phone);
}, []);
const handleSubmit = (event: React.MouseEvent) => {
@ -60,7 +56,7 @@ const VerifySmsModal: React.FC<IVerifySmsModal> = ({ onClose }) => {
return;
}
dispatch(reRequestPhoneVerification(phone)).then(() => {
dispatch(reRequestPhoneVerification(phone!)).then(() => {
dispatch(
snackbar.success(
intl.formatMessage({
@ -141,8 +137,7 @@ const VerifySmsModal: React.FC<IVerifySmsModal> = ({ onClose }) => {
case Statuses.READY:
return (
<FormGroup labelText='Phone Number'>
<Input
type='text'
<PhoneInput
value={phone}
onChange={onChange}
required

Wyświetl plik

@ -58,7 +58,7 @@ const ProfileDropdown: React.FC<IProfileDropdown> = ({ account, children }) => {
const renderAccount = (account: AccountEntity) => {
return (
<Account account={account} showProfileHoverCard={false} hideActions />
<Account account={account} showProfileHoverCard={false} withLinkToProfile={false} hideActions />
);
};

Wyświetl plik

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import {
@ -16,7 +16,6 @@ const messages = defineMessages({
subscribe: { id: 'account.subscribe', defaultMessage: 'Subscribe to notifications from @{name}' },
unsubscribe: { id: 'account.unsubscribe', defaultMessage: 'Unsubscribe to notifications from @{name}' },
subscribeSuccess: { id: 'account.subscribe.success', defaultMessage: 'You have subscribed to this account.' },
subscribeSuccessNotice: { id: 'account.subscribe.successNotice', defaultMessage: 'You have subscribed to this account, but your web notifications are disabled. Please enable them to receive notifications from @{name}.' },
unsubscribeSuccess: { id: 'account.unsubscribe.success', defaultMessage: 'You have unsubscribed from this account.' },
subscribeFailure: { id: 'account.subscribe.failure', defaultMessage: 'An error occurred trying to subscribed to this account.' },
unsubscribeFailure: { id: 'account.unsubscribe.failure', defaultMessage: 'An error occurred trying to unsubscribed to this account.' },
@ -31,14 +30,6 @@ const SubscriptionButton = ({ account }: ISubscriptionButton) => {
const features = useFeatures();
const intl = useIntl();
const [hasWebNotificationsEnabled, setWebNotificationsEnabled] = useState<boolean>(true);
const checkWebNotifications = () => {
Notification.requestPermission()
.then((value) => setWebNotificationsEnabled(value === 'granted'))
.catch(() => null);
};
const isFollowing = account.relationship?.following;
const isRequested = account.relationship?.requested;
const isSubscribed = features.accountNotifies ?
@ -48,13 +39,8 @@ const SubscriptionButton = ({ account }: ISubscriptionButton) => {
intl.formatMessage(messages.unsubscribe, { name: account.get('username') }) :
intl.formatMessage(messages.subscribe, { name: account.get('username') });
const onSubscribeSuccess = () => {
if (hasWebNotificationsEnabled) {
dispatch(snackbar.success(intl.formatMessage(messages.subscribeSuccess)));
} else {
dispatch(snackbar.info(intl.formatMessage(messages.subscribeSuccessNotice, { name: account.get('username') })));
}
};
const onSubscribeSuccess = () =>
dispatch(snackbar.success(intl.formatMessage(messages.subscribeSuccess)));
const onSubscribeFailure = () =>
dispatch(snackbar.error(intl.formatMessage(messages.subscribeFailure)));
@ -97,12 +83,6 @@ const SubscriptionButton = ({ account }: ISubscriptionButton) => {
}
};
useEffect(() => {
if (features.accountSubscriptions || features.accountNotifies) {
checkWebNotifications();
}
}, []);
if (!features.accountSubscriptions && !features.accountNotifies) {
return null;
}

Wyświetl plik

@ -9,6 +9,7 @@ import { Switch, useHistory, useLocation, Redirect } from 'react-router-dom';
import { fetchFollowRequests } from 'soapbox/actions/accounts';
import { fetchReports, fetchUsers, fetchConfig } from 'soapbox/actions/admin';
import { fetchAnnouncements } from 'soapbox/actions/announcements';
import { fetchChats } from 'soapbox/actions/chats';
import { uploadCompose, resetCompose } from 'soapbox/actions/compose';
import { fetchCustomEmojis } from 'soapbox/actions/custom_emojis';
@ -19,6 +20,7 @@ import { expandNotifications } from 'soapbox/actions/notifications';
import { register as registerPushNotifications } from 'soapbox/actions/push_notifications';
import { fetchScheduledStatuses } from 'soapbox/actions/scheduled_statuses';
import { connectUserStream } from 'soapbox/actions/streaming';
import { fetchSuggestionsForTimeline } from 'soapbox/actions/suggestions';
import { expandHomeTimeline } from 'soapbox/actions/timelines';
import Icon from 'soapbox/components/icon';
import SidebarNavigation from 'soapbox/components/sidebar-navigation';
@ -441,13 +443,17 @@ const UI: React.FC = ({ children }) => {
const loadAccountData = () => {
if (!account) return;
dispatch(expandHomeTimeline());
dispatch(expandHomeTimeline({}, () => {
dispatch(fetchSuggestionsForTimeline());
}));
dispatch(expandNotifications())
// @ts-ignore
.then(() => dispatch(fetchMarker(['notifications'])))
.catch(console.error);
dispatch(fetchAnnouncements());
if (features.chats) {
dispatch(fetchChats());
}

Wyświetl plik

@ -455,7 +455,7 @@ export function WhoToFollowPanel() {
}
export function FollowRecommendations() {
return import(/* webpackChunkName: "features/follow_recommendations" */'../../follow_recommendations');
return import(/* webpackChunkName: "features/follow-recommendations" */'../../follow-recommendations');
}
export function Directory() {
@ -487,7 +487,7 @@ export function CreateApp() {
}
export function SettingsStore() {
return import(/* webpackChunkName: "features/developers" */'../../developers/settings_store');
return import(/* webpackChunkName: "features/developers" */'../../developers/settings-store');
}
export function TestTimeline() {
@ -521,3 +521,7 @@ export function VerifySmsModal() {
export function FamiliarFollowersModal() {
return import(/*webpackChunkName: "modals/familiar_followers_modal" */'../components/familiar_followers_modal');
}
export function AnnouncementsPanel() {
return import(/* webpackChunkName: "features/announcements" */'../../../components/announcements/announcements-panel');
}

Wyświetl plik

@ -29,7 +29,7 @@ describe('<SmsVerification />', () => {
await userEvent.type(screen.getByLabelText('Phone Number'), '+1 (555) 555-5555');
await waitFor(() => {
fireEvent.submit(
screen.getByRole('button'), {
screen.getByRole('button', { name: 'Next' }), {
preventDefault: () => {},
},
);
@ -56,7 +56,7 @@ describe('<SmsVerification />', () => {
await userEvent.type(screen.getByLabelText('Phone Number'), '+1 (555) 555-5555');
await waitFor(() => {
fireEvent.submit(
screen.getByRole('button'), {
screen.getByRole('button', { name: 'Next' }), {
preventDefault: () => {},
},
);
@ -90,7 +90,7 @@ describe('<SmsVerification />', () => {
await userEvent.type(screen.getByLabelText('Phone Number'), '+1 (555) 555-5555');
await waitFor(() => {
fireEvent.submit(
screen.getByRole('button'), {
screen.getByRole('button', { name: 'Next' }), {
preventDefault: () => {},
},
);

Some files were not shown because too many files have changed in this diff Show More