Merge: Enhancements and component updates.
This commit is contained in:
commit
dafff2d349
|
@ -7,12 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Hashtags: let users follow hashtags (Mastodon, Akkoma).
|
||||
- Posts: Support posts filtering on recent Mastodon versions
|
||||
- Reactions: Support custom emoji reactions
|
||||
- Compatbility: Support Mastodon v2 timeline filters.
|
||||
- Compatbility: Preliminary support for Ditto backend.
|
||||
- Posts: Support dislikes on Friendica.
|
||||
- UI: added a character counter to some textareas.
|
||||
- UI: added new experience for viewing Media
|
||||
|
||||
### Changed
|
||||
- Posts: truncate Nostr pubkeys in reply mentions.
|
||||
|
|
|
@ -81,6 +81,7 @@ describe('initAccountNoteModal()', () => {
|
|||
}) as Account;
|
||||
const expectedActions = [
|
||||
{ type: 'ACCOUNT_NOTE_INIT_MODAL', account, comment: 'hello' },
|
||||
{ type: 'MODAL_CLOSE', modalType: 'ACCOUNT_NOTE' },
|
||||
{ type: 'MODAL_OPEN', modalType: 'ACCOUNT_NOTE' },
|
||||
];
|
||||
await store.dispatch(initAccountNoteModal(account));
|
||||
|
|
|
@ -123,6 +123,7 @@ describe('deleteStatus()', () => {
|
|||
withRedraft: true,
|
||||
id: 'compose-modal',
|
||||
},
|
||||
{ type: 'MODAL_CLOSE', modalType: 'COMPOSE', modalProps: undefined },
|
||||
{ type: 'MODAL_OPEN', modalType: 'COMPOSE', modalProps: undefined },
|
||||
];
|
||||
await store.dispatch(deleteStatus(statusId, true));
|
||||
|
|
|
@ -4,7 +4,7 @@ import { openModal, closeModal } from './modals';
|
|||
|
||||
import type { AxiosError } from 'axios';
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
import type { Account } from 'soapbox/types/entities';
|
||||
|
||||
const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST';
|
||||
|
@ -51,7 +51,7 @@ function submitAccountNoteFail(error: AxiosError) {
|
|||
};
|
||||
}
|
||||
|
||||
const initAccountNoteModal = (account: Account) => (dispatch: React.Dispatch<AnyAction>, getState: () => RootState) => {
|
||||
const initAccountNoteModal = (account: Account) => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const comment = getState().relationships.get(account.id)!.note;
|
||||
|
||||
dispatch({
|
||||
|
|
|
@ -127,7 +127,7 @@ const fetchConfig = () =>
|
|||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch({ type: ADMIN_CONFIG_FETCH_REQUEST });
|
||||
return api(getState)
|
||||
.get('/api/pleroma/admin/config')
|
||||
.get('/api/v1/pleroma/admin/config')
|
||||
.then(({ data }) => {
|
||||
dispatch({ type: ADMIN_CONFIG_FETCH_SUCCESS, configs: data.configs, needsReboot: data.need_reboot });
|
||||
}).catch(error => {
|
||||
|
@ -139,7 +139,7 @@ const updateConfig = (configs: Record<string, any>[]) =>
|
|||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch({ type: ADMIN_CONFIG_UPDATE_REQUEST, configs });
|
||||
return api(getState)
|
||||
.post('/api/pleroma/admin/config', { configs })
|
||||
.post('/api/v1/pleroma/admin/config', { configs })
|
||||
.then(({ data }) => {
|
||||
dispatch({ type: ADMIN_CONFIG_UPDATE_SUCCESS, configs: data.configs, needsReboot: data.need_reboot });
|
||||
}).catch(error => {
|
||||
|
@ -178,7 +178,7 @@ const fetchMastodonReports = (params: Record<string, any>) =>
|
|||
const fetchPleromaReports = (params: Record<string, any>) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) =>
|
||||
api(getState)
|
||||
.get('/api/pleroma/admin/reports', { params })
|
||||
.get('/api/v1/pleroma/admin/reports', { params })
|
||||
.then(({ data: { reports } }) => {
|
||||
reports.forEach((report: APIEntity) => {
|
||||
dispatch(importFetchedAccount(report.account));
|
||||
|
@ -224,7 +224,7 @@ const patchMastodonReports = (reports: { id: string, state: string }[]) =>
|
|||
const patchPleromaReports = (reports: { id: string, state: string }[]) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) =>
|
||||
api(getState)
|
||||
.patch('/api/pleroma/admin/reports', { reports })
|
||||
.patch('/api/v1/pleroma/admin/reports', { reports })
|
||||
.then(() => {
|
||||
dispatch({ type: ADMIN_REPORTS_PATCH_SUCCESS, reports });
|
||||
}).catch(error => {
|
||||
|
@ -286,7 +286,7 @@ const fetchPleromaUsers = (filters: string[], page: number, query?: string | nul
|
|||
if (query) params.query = query;
|
||||
|
||||
return api(getState)
|
||||
.get('/api/pleroma/admin/users', { params })
|
||||
.get('/api/v1/pleroma/admin/users', { params })
|
||||
.then(({ data: { users, count, page_size: pageSize } }) => {
|
||||
dispatch(fetchRelationships(users.map((user: APIEntity) => user.id)));
|
||||
dispatch({ type: ADMIN_USERS_FETCH_SUCCESS, users, count, pageSize, filters, page });
|
||||
|
@ -331,7 +331,7 @@ const deactivatePleromaUsers = (accountIds: string[]) =>
|
|||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const nicknames = nicknamesFromIds(getState, accountIds);
|
||||
return api(getState)
|
||||
.patch('/api/pleroma/admin/users/deactivate', { nicknames })
|
||||
.patch('/api/v1/pleroma/admin/users/deactivate', { nicknames })
|
||||
.then(({ data: { users } }) => {
|
||||
dispatch({ type: ADMIN_USERS_DEACTIVATE_SUCCESS, users, accountIds });
|
||||
}).catch(error => {
|
||||
|
@ -360,7 +360,7 @@ const deleteUsers = (accountIds: string[]) =>
|
|||
const nicknames = nicknamesFromIds(getState, accountIds);
|
||||
dispatch({ type: ADMIN_USERS_DELETE_REQUEST, accountIds });
|
||||
return api(getState)
|
||||
.delete('/api/pleroma/admin/users', { data: { nicknames } })
|
||||
.delete('/api/v1/pleroma/admin/users', { data: { nicknames } })
|
||||
.then(({ data: nicknames }) => {
|
||||
dispatch({ type: ADMIN_USERS_DELETE_SUCCESS, nicknames, accountIds });
|
||||
}).catch(error => {
|
||||
|
@ -384,7 +384,7 @@ const approvePleromaUsers = (accountIds: string[]) =>
|
|||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const nicknames = nicknamesFromIds(getState, accountIds);
|
||||
return api(getState)
|
||||
.patch('/api/pleroma/admin/users/approve', { nicknames })
|
||||
.patch('/api/v1/pleroma/admin/users/approve', { nicknames })
|
||||
.then(({ data: { users } }) => {
|
||||
dispatch({ type: ADMIN_USERS_APPROVE_SUCCESS, users, accountIds });
|
||||
}).catch(error => {
|
||||
|
@ -412,7 +412,7 @@ const deleteStatus = (id: string) =>
|
|||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch({ type: ADMIN_STATUS_DELETE_REQUEST, id });
|
||||
return api(getState)
|
||||
.delete(`/api/pleroma/admin/statuses/${id}`)
|
||||
.delete(`/api/v1/pleroma/admin/statuses/${id}`)
|
||||
.then(() => {
|
||||
dispatch({ type: ADMIN_STATUS_DELETE_SUCCESS, id });
|
||||
}).catch(error => {
|
||||
|
@ -424,7 +424,7 @@ const toggleStatusSensitivity = (id: string, sensitive: boolean) =>
|
|||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch({ type: ADMIN_STATUS_TOGGLE_SENSITIVITY_REQUEST, id });
|
||||
return api(getState)
|
||||
.put(`/api/pleroma/admin/statuses/${id}`, { sensitive: !sensitive })
|
||||
.put(`/api/v1/pleroma/admin/statuses/${id}`, { sensitive: !sensitive })
|
||||
.then(() => {
|
||||
dispatch({ type: ADMIN_STATUS_TOGGLE_SENSITIVITY_SUCCESS, id });
|
||||
}).catch(error => {
|
||||
|
@ -436,7 +436,7 @@ const fetchModerationLog = (params?: Record<string, any>) =>
|
|||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch({ type: ADMIN_LOG_FETCH_REQUEST });
|
||||
return api(getState)
|
||||
.get('/api/pleroma/admin/moderation_log', { params })
|
||||
.get('/api/v1/pleroma/admin/moderation_log', { params })
|
||||
.then(({ data }) => {
|
||||
dispatch({ type: ADMIN_LOG_FETCH_SUCCESS, items: data.items, total: data.total });
|
||||
return data;
|
||||
|
@ -567,7 +567,7 @@ const suggestUsers = (accountIds: string[]) =>
|
|||
const nicknames = nicknamesFromIds(getState, accountIds);
|
||||
dispatch({ type: ADMIN_USERS_SUGGEST_REQUEST, accountIds });
|
||||
return api(getState)
|
||||
.patch('/api/pleroma/admin/users/suggest', { nicknames })
|
||||
.patch('/api/v1/pleroma/admin/users/suggest', { nicknames })
|
||||
.then(({ data: { users } }) => {
|
||||
dispatch({ type: ADMIN_USERS_SUGGEST_SUCCESS, users, accountIds });
|
||||
}).catch(error => {
|
||||
|
@ -580,7 +580,7 @@ const unsuggestUsers = (accountIds: string[]) =>
|
|||
const nicknames = nicknamesFromIds(getState, accountIds);
|
||||
dispatch({ type: ADMIN_USERS_UNSUGGEST_REQUEST, accountIds });
|
||||
return api(getState)
|
||||
.patch('/api/pleroma/admin/users/unsuggest', { nicknames })
|
||||
.patch('/api/v1/pleroma/admin/users/unsuggest', { nicknames })
|
||||
.then(({ data: { users } }) => {
|
||||
dispatch({ type: ADMIN_USERS_UNSUGGEST_SUCCESS, users, accountIds });
|
||||
}).catch(error => {
|
||||
|
@ -636,7 +636,7 @@ const fetchAdminAnnouncements = () =>
|
|||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch({ type: ADMIN_ANNOUNCEMENTS_FETCH_REQUEST });
|
||||
return api(getState)
|
||||
.get('/api/pleroma/admin/announcements', { params: { limit: 50 } })
|
||||
.get('/api/v1/pleroma/admin/announcements', { params: { limit: 50 } })
|
||||
.then(({ data }) => {
|
||||
dispatch({ type: ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS, announcements: data });
|
||||
return data;
|
||||
|
@ -651,7 +651,7 @@ const expandAdminAnnouncements = () =>
|
|||
|
||||
dispatch({ type: ADMIN_ANNOUNCEMENTS_EXPAND_REQUEST });
|
||||
return api(getState)
|
||||
.get('/api/pleroma/admin/announcements', { params: { limit: 50, offset: page * 50 } })
|
||||
.get('/api/v1/pleroma/admin/announcements', { params: { limit: 50, offset: page * 50 } })
|
||||
.then(({ data }) => {
|
||||
dispatch({ type: ADMIN_ANNOUNCEMENTS_EXPAND_SUCCESS, announcements: data });
|
||||
return data;
|
||||
|
@ -687,7 +687,7 @@ const handleCreateAnnouncement = () =>
|
|||
const { id, content, starts_at, ends_at, all_day } = getState().admin_announcements.form;
|
||||
|
||||
return api(getState)[id ? 'patch' : 'post'](
|
||||
id ? `/api/pleroma/admin/announcements/${id}` : '/api/pleroma/admin/announcements',
|
||||
id ? `/api/v1/pleroma/admin/announcements/${id}` : '/api/v1/pleroma/admin/announcements',
|
||||
{ content, starts_at, ends_at, all_day },
|
||||
).then(({ data }) => {
|
||||
dispatch({ type: ADMIN_ANNOUNCEMENT_CREATE_SUCCESS, announcement: data });
|
||||
|
@ -703,7 +703,7 @@ const deleteAnnouncement = (id: string) =>
|
|||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch({ type: ADMIN_ANNOUNCEMENT_DELETE_REQUEST, id });
|
||||
|
||||
return api(getState).delete(`/api/pleroma/admin/announcements/${id}`).then(({ data }) => {
|
||||
return api(getState).delete(`/api/v1/pleroma/admin/announcements/${id}`).then(({ data }) => {
|
||||
dispatch({ type: ADMIN_ANNOUNCEMENT_DELETE_SUCCESS, id });
|
||||
toast.success(messages.announcementDeleteSuccess);
|
||||
dispatch(fetchAdminAnnouncements());
|
||||
|
|
|
@ -6,9 +6,8 @@ import api, { getLinks } from '../api';
|
|||
import { fetchRelationships } from './accounts';
|
||||
import { importFetchedAccounts } from './importer';
|
||||
|
||||
import type { AnyAction } from '@reduxjs/toolkit';
|
||||
import type { AxiosError } from 'axios';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
|
||||
const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST';
|
||||
const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS';
|
||||
|
@ -18,7 +17,7 @@ const BLOCKS_EXPAND_REQUEST = 'BLOCKS_EXPAND_REQUEST';
|
|||
const BLOCKS_EXPAND_SUCCESS = 'BLOCKS_EXPAND_SUCCESS';
|
||||
const BLOCKS_EXPAND_FAIL = 'BLOCKS_EXPAND_FAIL';
|
||||
|
||||
const fetchBlocks = () => (dispatch: React.Dispatch<AnyAction>, getState: () => RootState) => {
|
||||
const fetchBlocks = () => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
if (!isLoggedIn(getState)) return null;
|
||||
const nextLinkName = getNextLinkName(getState);
|
||||
|
||||
|
@ -54,7 +53,7 @@ function fetchBlocksFail(error: AxiosError) {
|
|||
};
|
||||
}
|
||||
|
||||
const expandBlocks = () => (dispatch: React.Dispatch<AnyAction>, getState: () => RootState) => {
|
||||
const expandBlocks = () => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
if (!isLoggedIn(getState)) return null;
|
||||
const nextLinkName = getNextLinkName(getState);
|
||||
|
||||
|
|
|
@ -1,40 +1,14 @@
|
|||
import { RootState } from 'soapbox/store';
|
||||
import { AppDispatch, RootState } from 'soapbox/store';
|
||||
|
||||
import api from '../api';
|
||||
|
||||
import { ACCOUNTS_IMPORT, importFetchedAccounts } from './importer';
|
||||
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
import { importFetchedAccounts } from './importer';
|
||||
|
||||
export const FAMILIAR_FOLLOWERS_FETCH_REQUEST = 'FAMILIAR_FOLLOWERS_FETCH_REQUEST';
|
||||
export const FAMILIAR_FOLLOWERS_FETCH_SUCCESS = 'FAMILIAR_FOLLOWERS_FETCH_SUCCESS';
|
||||
export const FAMILIAR_FOLLOWERS_FETCH_FAIL = 'FAMILIAR_FOLLOWERS_FETCH_FAIL';
|
||||
|
||||
type FamiliarFollowersFetchRequestAction = {
|
||||
type: typeof FAMILIAR_FOLLOWERS_FETCH_REQUEST
|
||||
id: string
|
||||
}
|
||||
|
||||
type FamiliarFollowersFetchRequestSuccessAction = {
|
||||
type: typeof FAMILIAR_FOLLOWERS_FETCH_SUCCESS
|
||||
id: string
|
||||
accounts: Array<APIEntity>
|
||||
}
|
||||
|
||||
type FamiliarFollowersFetchRequestFailAction = {
|
||||
type: typeof FAMILIAR_FOLLOWERS_FETCH_FAIL
|
||||
id: string
|
||||
error: any
|
||||
}
|
||||
|
||||
type AccountsImportAction = {
|
||||
type: typeof ACCOUNTS_IMPORT
|
||||
accounts: Array<APIEntity>
|
||||
}
|
||||
|
||||
export type FamiliarFollowersActions = FamiliarFollowersFetchRequestAction | FamiliarFollowersFetchRequestSuccessAction | FamiliarFollowersFetchRequestFailAction | AccountsImportAction
|
||||
|
||||
export const fetchAccountFamiliarFollowers = (accountId: string) => (dispatch: React.Dispatch<FamiliarFollowersActions>, getState: () => RootState) => {
|
||||
export const fetchAccountFamiliarFollowers = (accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch({
|
||||
type: FAMILIAR_FOLLOWERS_FETCH_REQUEST,
|
||||
id: accountId,
|
||||
|
@ -44,7 +18,7 @@ export const fetchAccountFamiliarFollowers = (accountId: string) => (dispatch: R
|
|||
.then(({ data }) => {
|
||||
const accounts = data.find(({ id }: { id: string }) => id === accountId).accounts;
|
||||
|
||||
dispatch(importFetchedAccounts(accounts) as AccountsImportAction);
|
||||
dispatch(importFetchedAccounts(accounts));
|
||||
dispatch({
|
||||
type: FAMILIAR_FOLLOWERS_FETCH_SUCCESS,
|
||||
id: accountId,
|
||||
|
|
|
@ -160,7 +160,7 @@ const favourite = (status: StatusEntity) =>
|
|||
|
||||
dispatch(favouriteRequest(status));
|
||||
|
||||
api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function(response) {
|
||||
api(getState).post(`/api/v1/statuses/${status.id}/favourite`).then(function(response) {
|
||||
dispatch(favouriteSuccess(status));
|
||||
}).catch(function(error) {
|
||||
dispatch(favouriteFail(status, error));
|
||||
|
@ -173,7 +173,7 @@ const unfavourite = (status: StatusEntity) =>
|
|||
|
||||
dispatch(unfavouriteRequest(status));
|
||||
|
||||
api(getState).post(`/api/v1/statuses/${status.get('id')}/unfavourite`).then(() => {
|
||||
api(getState).post(`/api/v1/statuses/${status.id}/unfavourite`).then(() => {
|
||||
dispatch(unfavouriteSuccess(status));
|
||||
}).catch(error => {
|
||||
dispatch(unfavouriteFail(status, error));
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { AppDispatch } from 'soapbox/store';
|
||||
|
||||
import type { ModalType } from 'soapbox/features/ui/components/modal-root';
|
||||
|
||||
export const MODAL_OPEN = 'MODAL_OPEN';
|
||||
|
@ -5,13 +7,18 @@ export const MODAL_CLOSE = 'MODAL_CLOSE';
|
|||
|
||||
/** Open a modal of the given type */
|
||||
export function openModal(type: ModalType, props?: any) {
|
||||
return {
|
||||
type: MODAL_OPEN,
|
||||
modalType: type,
|
||||
modalProps: props,
|
||||
return (dispatch: AppDispatch) => {
|
||||
dispatch(closeModal(type));
|
||||
dispatch(openModalSuccess(type, props));
|
||||
};
|
||||
}
|
||||
|
||||
const openModalSuccess = (type: ModalType, props?: any) => ({
|
||||
type: MODAL_OPEN,
|
||||
modalType: type,
|
||||
modalProps: props,
|
||||
});
|
||||
|
||||
/** Close the modal */
|
||||
export function closeModal(type?: ModalType) {
|
||||
return {
|
||||
|
|
|
@ -6,9 +6,11 @@
|
|||
* @see module:soapbox/actions/auth
|
||||
*/
|
||||
|
||||
import { getBaseURL } from 'soapbox/utils/state';
|
||||
|
||||
import { baseClient } from '../api';
|
||||
|
||||
import type { AppDispatch } from 'soapbox/store';
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
|
||||
export const OAUTH_TOKEN_CREATE_REQUEST = 'OAUTH_TOKEN_CREATE_REQUEST';
|
||||
export const OAUTH_TOKEN_CREATE_SUCCESS = 'OAUTH_TOKEN_CREATE_SUCCESS';
|
||||
|
@ -31,9 +33,10 @@ export const obtainOAuthToken = (params: Record<string, string | undefined>, bas
|
|||
};
|
||||
|
||||
export const revokeOAuthToken = (params: Record<string, string>) =>
|
||||
(dispatch: AppDispatch) => {
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch({ type: OAUTH_TOKEN_REVOKE_REQUEST, params });
|
||||
return baseClient().post('/oauth/revoke', params).then(({ data }) => {
|
||||
const baseURL = getBaseURL(getState());
|
||||
return baseClient(null, baseURL).post('/oauth/revoke', params).then(({ data }) => {
|
||||
dispatch({ type: OAUTH_TOKEN_REVOKE_SUCCESS, params, data });
|
||||
return data;
|
||||
}).catch(error => {
|
||||
|
|
|
@ -119,17 +119,22 @@ const setFilter = (filterType: SearchFilter) =>
|
|||
};
|
||||
|
||||
const expandSearch = (type: SearchFilter) => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const value = getState().search.value;
|
||||
const offset = getState().search.results[type].size;
|
||||
const value = getState().search.value;
|
||||
const offset = getState().search.results[type].size;
|
||||
const accountId = getState().search.accountId;
|
||||
|
||||
dispatch(expandSearchRequest(type));
|
||||
|
||||
const params: Record<string, any> = {
|
||||
q: value,
|
||||
type,
|
||||
offset,
|
||||
};
|
||||
|
||||
if (accountId) params.account_id = accountId;
|
||||
|
||||
api(getState).get('/api/v2/search', {
|
||||
params: {
|
||||
q: value,
|
||||
type,
|
||||
offset,
|
||||
},
|
||||
params,
|
||||
}).then(({ data }) => {
|
||||
if (data.accounts) {
|
||||
dispatch(importFetchedAccounts(data.accounts));
|
||||
|
|
|
@ -95,7 +95,7 @@ const connectTimelineStream = (
|
|||
dispatch(disconnectTimeline(timelineId));
|
||||
},
|
||||
|
||||
onReceive(data: any) {
|
||||
onReceive(websocket, data: any) {
|
||||
switch (data.event) {
|
||||
case 'update':
|
||||
dispatch(processTimelineUpdate(timelineId, JSON.parse(data.payload), accept));
|
||||
|
@ -181,6 +181,11 @@ const connectTimelineStream = (
|
|||
case 'marker':
|
||||
dispatch({ type: MARKER_FETCH_SUCCESS, marker: JSON.parse(data.payload) });
|
||||
break;
|
||||
case 'nostr.sign':
|
||||
window.nostr?.signEvent(JSON.parse(data.payload))
|
||||
.then((data) => websocket.send(JSON.stringify({ type: 'nostr.sign', data })))
|
||||
.catch(() => console.warn('Failed to sign Nostr event.'));
|
||||
break;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
@ -215,6 +220,9 @@ const connectListStream = (id: string) =>
|
|||
const connectGroupStream = (id: string) =>
|
||||
connectTimelineStream(`group:${id}`, `group&group=${id}`);
|
||||
|
||||
const connectNostrStream = () =>
|
||||
connectTimelineStream('nostr', 'nostr');
|
||||
|
||||
export {
|
||||
STREAMING_CHAT_UPDATE,
|
||||
STREAMING_FOLLOW_RELATIONSHIPS_UPDATE,
|
||||
|
@ -227,4 +235,5 @@ export {
|
|||
connectDirectStream,
|
||||
connectListStream,
|
||||
connectGroupStream,
|
||||
connectNostrStream,
|
||||
};
|
||||
|
|
201
app/soapbox/actions/tags.ts
Normal file
201
app/soapbox/actions/tags.ts
Normal file
|
@ -0,0 +1,201 @@
|
|||
import api, { getLinks } from '../api';
|
||||
|
||||
import type { AxiosError } from 'axios';
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST';
|
||||
const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
|
||||
const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL';
|
||||
|
||||
const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST';
|
||||
const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
|
||||
const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL';
|
||||
|
||||
const HASHTAG_UNFOLLOW_REQUEST = 'HASHTAG_UNFOLLOW_REQUEST';
|
||||
const HASHTAG_UNFOLLOW_SUCCESS = 'HASHTAG_UNFOLLOW_SUCCESS';
|
||||
const HASHTAG_UNFOLLOW_FAIL = 'HASHTAG_UNFOLLOW_FAIL';
|
||||
|
||||
const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
|
||||
const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
|
||||
const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
|
||||
|
||||
const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST';
|
||||
const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
|
||||
const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
|
||||
|
||||
const fetchHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(fetchHashtagRequest());
|
||||
|
||||
api(getState).get(`/api/v1/tags/${name}`).then(({ data }) => {
|
||||
dispatch(fetchHashtagSuccess(name, data));
|
||||
}).catch(err => {
|
||||
dispatch(fetchHashtagFail(err));
|
||||
});
|
||||
};
|
||||
|
||||
const fetchHashtagRequest = () => ({
|
||||
type: HASHTAG_FETCH_REQUEST,
|
||||
});
|
||||
|
||||
const fetchHashtagSuccess = (name: string, tag: APIEntity) => ({
|
||||
type: HASHTAG_FETCH_SUCCESS,
|
||||
name,
|
||||
tag,
|
||||
});
|
||||
|
||||
const fetchHashtagFail = (error: AxiosError) => ({
|
||||
type: HASHTAG_FETCH_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
const followHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(followHashtagRequest(name));
|
||||
|
||||
api(getState).post(`/api/v1/tags/${name}/follow`).then(({ data }) => {
|
||||
dispatch(followHashtagSuccess(name, data));
|
||||
}).catch(err => {
|
||||
dispatch(followHashtagFail(name, err));
|
||||
});
|
||||
};
|
||||
|
||||
const followHashtagRequest = (name: string) => ({
|
||||
type: HASHTAG_FOLLOW_REQUEST,
|
||||
name,
|
||||
});
|
||||
|
||||
const followHashtagSuccess = (name: string, tag: APIEntity) => ({
|
||||
type: HASHTAG_FOLLOW_SUCCESS,
|
||||
name,
|
||||
tag,
|
||||
});
|
||||
|
||||
const followHashtagFail = (name: string, error: AxiosError) => ({
|
||||
type: HASHTAG_FOLLOW_FAIL,
|
||||
name,
|
||||
error,
|
||||
});
|
||||
|
||||
const unfollowHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(unfollowHashtagRequest(name));
|
||||
|
||||
api(getState).post(`/api/v1/tags/${name}/unfollow`).then(({ data }) => {
|
||||
dispatch(unfollowHashtagSuccess(name, data));
|
||||
}).catch(err => {
|
||||
dispatch(unfollowHashtagFail(name, err));
|
||||
});
|
||||
};
|
||||
|
||||
const unfollowHashtagRequest = (name: string) => ({
|
||||
type: HASHTAG_UNFOLLOW_REQUEST,
|
||||
name,
|
||||
});
|
||||
|
||||
const unfollowHashtagSuccess = (name: string, tag: APIEntity) => ({
|
||||
type: HASHTAG_UNFOLLOW_SUCCESS,
|
||||
name,
|
||||
tag,
|
||||
});
|
||||
|
||||
const unfollowHashtagFail = (name: string, error: AxiosError) => ({
|
||||
type: HASHTAG_UNFOLLOW_FAIL,
|
||||
name,
|
||||
error,
|
||||
});
|
||||
|
||||
const fetchFollowedHashtags = () => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(fetchFollowedHashtagsRequest());
|
||||
|
||||
api(getState).get('/api/v1/followed_tags').then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null));
|
||||
}).catch(err => {
|
||||
dispatch(fetchFollowedHashtagsFail(err));
|
||||
});
|
||||
};
|
||||
|
||||
const fetchFollowedHashtagsRequest = () => ({
|
||||
type: FOLLOWED_HASHTAGS_FETCH_REQUEST,
|
||||
});
|
||||
|
||||
const fetchFollowedHashtagsSuccess = (followed_tags: APIEntity[], next: string | null) => ({
|
||||
type: FOLLOWED_HASHTAGS_FETCH_SUCCESS,
|
||||
followed_tags,
|
||||
next,
|
||||
});
|
||||
|
||||
const fetchFollowedHashtagsFail = (error: AxiosError) => ({
|
||||
type: FOLLOWED_HASHTAGS_FETCH_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
const expandFollowedHashtags = () => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const url = getState().followed_tags.next;
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandFollowedHashtagsRequest());
|
||||
|
||||
api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null));
|
||||
}).catch(error => {
|
||||
dispatch(expandFollowedHashtagsFail(error));
|
||||
});
|
||||
};
|
||||
|
||||
const expandFollowedHashtagsRequest = () => ({
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_REQUEST,
|
||||
});
|
||||
|
||||
const expandFollowedHashtagsSuccess = (followed_tags: APIEntity[], next: string | null) => ({
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
|
||||
followed_tags,
|
||||
next,
|
||||
});
|
||||
|
||||
const expandFollowedHashtagsFail = (error: AxiosError) => ({
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
|
||||
export {
|
||||
HASHTAG_FETCH_REQUEST,
|
||||
HASHTAG_FETCH_SUCCESS,
|
||||
HASHTAG_FETCH_FAIL,
|
||||
HASHTAG_FOLLOW_REQUEST,
|
||||
HASHTAG_FOLLOW_SUCCESS,
|
||||
HASHTAG_FOLLOW_FAIL,
|
||||
HASHTAG_UNFOLLOW_REQUEST,
|
||||
HASHTAG_UNFOLLOW_SUCCESS,
|
||||
HASHTAG_UNFOLLOW_FAIL,
|
||||
FOLLOWED_HASHTAGS_FETCH_REQUEST,
|
||||
FOLLOWED_HASHTAGS_FETCH_SUCCESS,
|
||||
FOLLOWED_HASHTAGS_FETCH_FAIL,
|
||||
FOLLOWED_HASHTAGS_EXPAND_REQUEST,
|
||||
FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
|
||||
FOLLOWED_HASHTAGS_EXPAND_FAIL,
|
||||
fetchHashtag,
|
||||
fetchHashtagRequest,
|
||||
fetchHashtagSuccess,
|
||||
fetchHashtagFail,
|
||||
followHashtag,
|
||||
followHashtagRequest,
|
||||
followHashtagSuccess,
|
||||
followHashtagFail,
|
||||
unfollowHashtag,
|
||||
unfollowHashtagRequest,
|
||||
unfollowHashtagSuccess,
|
||||
unfollowHashtagFail,
|
||||
fetchFollowedHashtags,
|
||||
fetchFollowedHashtagsRequest,
|
||||
fetchFollowedHashtagsSuccess,
|
||||
fetchFollowedHashtagsFail,
|
||||
expandFollowedHashtags,
|
||||
expandFollowedHashtagsRequest,
|
||||
expandFollowedHashtagsSuccess,
|
||||
expandFollowedHashtagsFail,
|
||||
};
|
41
app/soapbox/api/hooks/groups/__tests__/useGroup.test.ts
Normal file
41
app/soapbox/api/hooks/groups/__tests__/useGroup.test.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { __stub } from 'soapbox/api';
|
||||
import { buildGroup } from 'soapbox/jest/factory';
|
||||
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
|
||||
|
||||
import { useGroup } from '../useGroup';
|
||||
|
||||
const group = buildGroup({ id: '1', display_name: 'soapbox' });
|
||||
|
||||
describe('useGroup hook', () => {
|
||||
describe('with a successful request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/groups/${group.id}`).reply(200, group);
|
||||
});
|
||||
});
|
||||
|
||||
it('is successful', async () => {
|
||||
const { result } = renderHook(() => useGroup(group.id));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.group?.id).toBe(group.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful query', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/groups/${group.id}`).networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('is has error state', async() => {
|
||||
const { result } = renderHook(() => useGroup(group.id));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.group).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,41 @@
|
|||
import { __stub } from 'soapbox/api';
|
||||
import { buildGroup } from 'soapbox/jest/factory';
|
||||
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
|
||||
|
||||
import { useGroupLookup } from '../useGroupLookup';
|
||||
|
||||
const group = buildGroup({ id: '1', slug: 'soapbox' });
|
||||
|
||||
describe('useGroupLookup hook', () => {
|
||||
describe('with a successful request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/groups/lookup?name=${group.slug}`).reply(200, group);
|
||||
});
|
||||
});
|
||||
|
||||
it('is successful', async () => {
|
||||
const { result } = renderHook(() => useGroupLookup(group.slug));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.entity?.id).toBe(group.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful query', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/groups/lookup?name=${group.slug}`).networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('is has error state', async() => {
|
||||
const { result } = renderHook(() => useGroupLookup(group.slug));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.entity).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
44
app/soapbox/api/hooks/groups/__tests__/useGroupMedia.test.ts
Normal file
44
app/soapbox/api/hooks/groups/__tests__/useGroupMedia.test.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { __stub } from 'soapbox/api';
|
||||
import { buildStatus } from 'soapbox/jest/factory';
|
||||
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
|
||||
|
||||
import { useGroupMedia } from '../useGroupMedia';
|
||||
|
||||
const status = buildStatus();
|
||||
const groupId = '1';
|
||||
|
||||
describe('useGroupMedia hook', () => {
|
||||
describe('with a successful request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/timelines/group/${groupId}?only_media=true`).reply(200, [status]);
|
||||
});
|
||||
});
|
||||
|
||||
it('is successful', async () => {
|
||||
const { result } = renderHook(() => useGroupMedia(groupId));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.entities.length).toBe(1);
|
||||
expect(result.current.entities[0].id).toBe(status.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful query', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/timelines/group/${groupId}?only_media=true`).networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('is has error state', async() => {
|
||||
const { result } = renderHook(() => useGroupMedia(groupId));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.entities.length).toBe(0);
|
||||
expect(result.current.isError).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,45 @@
|
|||
import { __stub } from 'soapbox/api';
|
||||
import { buildGroupMember } from 'soapbox/jest/factory';
|
||||
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
|
||||
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||
|
||||
import { useGroupMembers } from '../useGroupMembers';
|
||||
|
||||
const groupMember = buildGroupMember();
|
||||
const groupId = '1';
|
||||
|
||||
describe('useGroupMembers hook', () => {
|
||||
describe('with a successful request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/groups/${groupId}/memberships?role=${GroupRoles.ADMIN}`).reply(200, [groupMember]);
|
||||
});
|
||||
});
|
||||
|
||||
it('is successful', async () => {
|
||||
const { result } = renderHook(() => useGroupMembers(groupId, GroupRoles.ADMIN));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.groupMembers.length).toBe(1);
|
||||
expect(result.current.groupMembers[0].id).toBe(groupMember.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful query', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/groups/${groupId}/memberships?role=${GroupRoles.ADMIN}`).networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('is has error state', async() => {
|
||||
const { result } = renderHook(() => useGroupMembers(groupId, GroupRoles.ADMIN));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.groupMembers.length).toBe(0);
|
||||
expect(result.current.isError).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
47
app/soapbox/api/hooks/groups/__tests__/useGroups.test.ts
Normal file
47
app/soapbox/api/hooks/groups/__tests__/useGroups.test.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { __stub } from 'soapbox/api';
|
||||
import { buildGroup } from 'soapbox/jest/factory';
|
||||
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
|
||||
import { normalizeInstance } from 'soapbox/normalizers';
|
||||
|
||||
import { useGroups } from '../useGroups';
|
||||
|
||||
const group = buildGroup({ id: '1', display_name: 'soapbox' });
|
||||
const store = {
|
||||
instance: normalizeInstance({
|
||||
version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)',
|
||||
}),
|
||||
};
|
||||
|
||||
describe('useGroups hook', () => {
|
||||
describe('with a successful request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/groups').reply(200, [group]);
|
||||
});
|
||||
});
|
||||
|
||||
it('is successful', async () => {
|
||||
const { result } = renderHook(useGroups, undefined, store);
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.groups).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful query', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/groups').networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('is has error state', async() => {
|
||||
const { result } = renderHook(useGroups, undefined, store);
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.groups).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -3,15 +3,24 @@ import { useEntityLookup } from 'soapbox/entity-store/hooks';
|
|||
import { useApi } from 'soapbox/hooks/useApi';
|
||||
import { groupSchema } from 'soapbox/schemas';
|
||||
|
||||
import { useGroupRelationship } from './useGroupRelationship';
|
||||
|
||||
function useGroupLookup(slug: string) {
|
||||
const api = useApi();
|
||||
|
||||
return useEntityLookup(
|
||||
const { entity: group, ...result } = useEntityLookup(
|
||||
Entities.GROUPS,
|
||||
(group) => group.slug === slug,
|
||||
() => api.get(`/api/v1/groups/lookup?name=${slug}`),
|
||||
{ schema: groupSchema },
|
||||
);
|
||||
|
||||
const { entity: relationship } = useGroupRelationship(group?.id);
|
||||
|
||||
return {
|
||||
...result,
|
||||
entity: group ? { ...group, relationship: relationship || null } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export { useGroupLookup };
|
|
@ -7,14 +7,17 @@ import { useEntity } from 'soapbox/entity-store/hooks';
|
|||
import { useApi, useAppDispatch } from 'soapbox/hooks';
|
||||
import { type GroupRelationship, groupRelationshipSchema } from 'soapbox/schemas';
|
||||
|
||||
function useGroupRelationship(groupId: string) {
|
||||
function useGroupRelationship(groupId: string | undefined) {
|
||||
const api = useApi();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { entity: groupRelationship, ...result } = useEntity<GroupRelationship>(
|
||||
[Entities.GROUP_RELATIONSHIPS, groupId],
|
||||
[Entities.GROUP_RELATIONSHIPS, groupId as string],
|
||||
() => api.get(`/api/v1/groups/relationships?id[]=${groupId}`),
|
||||
{ schema: z.array(groupRelationshipSchema).transform(arr => arr[0]) },
|
||||
{
|
||||
enabled: !!groupId,
|
||||
schema: z.array(groupRelationshipSchema).transform(arr => arr[0]),
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import React from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import Bundle from 'soapbox/features/ui/components/bundle';
|
||||
import { MediaGallery } from 'soapbox/features/ui/util/async-components';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
import type { Attachment } from 'soapbox/types/entities';
|
||||
|
@ -16,7 +16,7 @@ interface IAttachmentThumbs {
|
|||
|
||||
const AttachmentThumbs = (props: IAttachmentThumbs) => {
|
||||
const { media, onClick, sensitive } = props;
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const renderLoading = () => <div className='media-gallery--compact' />;
|
||||
const onOpenMedia = (media: ImmutableList<Attachment>, index: number) => dispatch(openModal('MEDIA', { media, index }));
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { offset, Placement, useFloating, flip, arrow } from '@floating-ui/react';
|
||||
import { offset, Placement, useFloating, flip, arrow, shift } from '@floating-ui/react';
|
||||
import clsx from 'clsx';
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
@ -65,6 +65,9 @@ const DropdownMenu = (props: IDropdownMenu) => {
|
|||
middleware: [
|
||||
offset(12),
|
||||
flip(),
|
||||
shift({
|
||||
padding: 8,
|
||||
}),
|
||||
arrow({
|
||||
element: arrowRef,
|
||||
}),
|
||||
|
|
|
@ -252,6 +252,7 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
|
|||
className={clsx({
|
||||
'my-2 mx-auto relative pointer-events-none flex items-center min-h-[calc(100%-3.5rem)]': true,
|
||||
'p-4 md:p-0': type !== 'MEDIA',
|
||||
'!my-0': type === 'MEDIA',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
|
|
|
@ -96,14 +96,16 @@ interface IStatusActionBar {
|
|||
status: Status
|
||||
withLabels?: boolean
|
||||
expandable?: boolean
|
||||
space?: 'expand' | 'compact'
|
||||
space?: 'sm' | 'md' | 'lg'
|
||||
statusActionButtonTheme?: 'default' | 'inverse'
|
||||
}
|
||||
|
||||
const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||
status,
|
||||
withLabels = false,
|
||||
expandable = true,
|
||||
space = 'compact',
|
||||
space = 'sm',
|
||||
statusActionButtonTheme = 'default',
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
|
@ -572,6 +574,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
onClick={handleReblogClick}
|
||||
count={reblogCount}
|
||||
text={withLabels ? intl.formatMessage(messages.reblog) : undefined}
|
||||
theme={statusActionButtonTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -583,13 +586,22 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
|
||||
const canShare = ('share' in navigator) && (status.visibility === 'public' || status.visibility === 'group');
|
||||
|
||||
const spacing: {
|
||||
[key: string]: React.ComponentProps<typeof HStack>['space']
|
||||
} = {
|
||||
'sm': 2,
|
||||
'md': 8,
|
||||
'lg': 0, // using justifyContent instead on the HStack
|
||||
};
|
||||
|
||||
return (
|
||||
<HStack data-testid='status-action-bar'>
|
||||
<HStack
|
||||
justifyContent={space === 'expand' ? 'between' : undefined}
|
||||
space={space === 'compact' ? 2 : undefined}
|
||||
grow={space === 'expand'}
|
||||
justifyContent={space === 'lg' ? 'between' : undefined}
|
||||
space={spacing[space]}
|
||||
grow={space === 'lg'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
alignItems='center'
|
||||
>
|
||||
<GroupPopover
|
||||
group={status.group as any}
|
||||
|
@ -602,6 +614,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
count={replyCount}
|
||||
text={withLabels ? intl.formatMessage(messages.reply) : undefined}
|
||||
disabled={replyDisabled}
|
||||
theme={statusActionButtonTheme}
|
||||
/>
|
||||
</GroupPopover>
|
||||
|
||||
|
@ -628,6 +641,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
count={emojiReactCount}
|
||||
emoji={meEmojiReact}
|
||||
text={withLabels ? meEmojiTitle : undefined}
|
||||
theme={statusActionButtonTheme}
|
||||
/>
|
||||
</StatusReactionWrapper>
|
||||
) : (
|
||||
|
@ -640,6 +654,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
active={Boolean(meEmojiName)}
|
||||
count={favouriteCount}
|
||||
text={withLabels ? meEmojiTitle : undefined}
|
||||
theme={statusActionButtonTheme}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
@ -653,6 +668,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
active={status.disliked}
|
||||
count={status.dislikes_count}
|
||||
text={withLabels ? intl.formatMessage(messages.disfavourite) : undefined}
|
||||
theme={statusActionButtonTheme}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
@ -661,6 +677,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
title={intl.formatMessage(messages.share)}
|
||||
icon={require('@tabler/icons/upload.svg')}
|
||||
onClick={handleShareClick}
|
||||
theme={statusActionButtonTheme}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
@ -668,6 +685,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
<StatusActionButton
|
||||
title={intl.formatMessage(messages.more)}
|
||||
icon={require('@tabler/icons/dots.svg')}
|
||||
theme={statusActionButtonTheme}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
</HStack>
|
||||
|
|
|
@ -35,10 +35,11 @@ interface IStatusActionButton extends React.ButtonHTMLAttributes<HTMLButtonEleme
|
|||
filled?: boolean
|
||||
emoji?: ImmutableMap<string, any>
|
||||
text?: React.ReactNode
|
||||
theme?: 'default' | 'inverse'
|
||||
}
|
||||
|
||||
const StatusActionButton = React.forwardRef<HTMLButtonElement, IStatusActionButton>((props, ref): JSX.Element => {
|
||||
const { icon, className, iconClassName, active, color, filled = false, count = 0, emoji, text, ...filteredProps } = props;
|
||||
const { icon, className, iconClassName, active, color, filled = false, count = 0, emoji, text, theme = 'default', ...filteredProps } = props;
|
||||
|
||||
const renderIcon = () => {
|
||||
if (emoji) {
|
||||
|
@ -82,10 +83,10 @@ const StatusActionButton = React.forwardRef<HTMLButtonElement, IStatusActionButt
|
|||
type='button'
|
||||
className={clsx(
|
||||
'flex items-center rounded-full p-1 rtl:space-x-reverse',
|
||||
'text-gray-600 hover:text-gray-600 dark:hover:text-white',
|
||||
'bg-white dark:bg-transparent',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:ring-offset-0',
|
||||
{
|
||||
'text-gray-600 hover:text-gray-600 dark:hover:text-white bg-white dark:bg-transparent': theme === 'default',
|
||||
'text-white/80 hover:text-white bg-transparent dark:bg-transparent': theme === 'inverse',
|
||||
'text-black dark:text-white': active && emoji,
|
||||
'hover:text-gray-600 dark:hover:text-white': !filteredProps.disabled,
|
||||
'text-accent-300 hover:text-accent-300 dark:hover:text-accent-300': active && !emoji && color === COLORS.accent,
|
||||
|
|
|
@ -4,8 +4,6 @@ import { FormattedMessage } from 'react-intl';
|
|||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { useSoapboxConfig } from 'soapbox/hooks';
|
||||
import { addGreentext } from 'soapbox/utils/greentext';
|
||||
import { onlyEmoji as isOnlyEmoji } from 'soapbox/utils/rich-content';
|
||||
|
||||
import { isRtl } from '../rtl';
|
||||
|
@ -54,8 +52,6 @@ const StatusContent: React.FC<IStatusContent> = ({
|
|||
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { greentext } = useSoapboxConfig();
|
||||
|
||||
const onMentionClick = (mention: Mention, e: MouseEvent) => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
|
@ -134,13 +130,7 @@ const StatusContent: React.FC<IStatusContent> = ({
|
|||
});
|
||||
|
||||
const parsedHtml = useMemo((): string => {
|
||||
const html = translatable && status.translation ? status.translation.get('content')! : status.contentHtml;
|
||||
|
||||
if (greentext) {
|
||||
return addGreentext(html);
|
||||
} else {
|
||||
return html;
|
||||
}
|
||||
return translatable && status.translation ? status.translation.get('content')! : status.contentHtml;
|
||||
}, [status.contentHtml, status.translation]);
|
||||
|
||||
if (status.content.length === 0) {
|
||||
|
|
|
@ -6,15 +6,15 @@ import { Text } from 'soapbox/components/ui';
|
|||
|
||||
interface ITombstone {
|
||||
id: string
|
||||
onMoveUp: (statusId: string) => void
|
||||
onMoveDown: (statusId: string) => void
|
||||
onMoveUp?: (statusId: string) => void
|
||||
onMoveDown?: (statusId: string) => void
|
||||
}
|
||||
|
||||
/** Represents a deleted item. */
|
||||
const Tombstone: React.FC<ITombstone> = ({ id, onMoveUp, onMoveDown }) => {
|
||||
const handlers = {
|
||||
moveUp: () => onMoveUp(id),
|
||||
moveDown: () => onMoveDown(id),
|
||||
moveUp: () => onMoveUp?.(id),
|
||||
moveDown: () => onMoveDown?.(id),
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
@ -51,6 +51,8 @@ export interface IColumn {
|
|||
withHeader?: boolean
|
||||
/** Extra class name for top <div> element. */
|
||||
className?: string
|
||||
/** Extra class name for the <CardBody> element. */
|
||||
bodyClassName?: string
|
||||
/** Ref forwarded to column. */
|
||||
ref?: React.Ref<HTMLDivElement>
|
||||
/** Children to display in the column. */
|
||||
|
@ -63,7 +65,7 @@ export interface IColumn {
|
|||
|
||||
/** A backdrop for the main section of the UI. */
|
||||
const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedRef<HTMLDivElement>): JSX.Element => {
|
||||
const { backHref, children, label, transparent = false, withHeader = true, className, action, size } = props;
|
||||
const { backHref, children, label, transparent = false, withHeader = true, className, bodyClassName, action, size } = props;
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
|
||||
|
@ -109,7 +111,7 @@ const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedR
|
|||
/>
|
||||
)}
|
||||
|
||||
<CardBody>
|
||||
<CardBody className={bodyClassName}>
|
||||
{children}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
|
|
@ -18,6 +18,7 @@ const alignItemsOptions = {
|
|||
};
|
||||
|
||||
const spaces = {
|
||||
0: 'space-x-0',
|
||||
[0.5]: 'space-x-0.5',
|
||||
1: 'space-x-1',
|
||||
1.5: 'space-x-1.5',
|
||||
|
|
|
@ -12,7 +12,7 @@ interface IIconButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|||
/** Text to display next ot the button. */
|
||||
text?: string
|
||||
/** Predefined styles to display for the button. */
|
||||
theme?: 'seamless' | 'outlined' | 'secondary' | 'transparent'
|
||||
theme?: 'seamless' | 'outlined' | 'secondary' | 'transparent' | 'dark'
|
||||
/** Override the data-testid */
|
||||
'data-testid'?: string
|
||||
}
|
||||
|
@ -29,6 +29,7 @@ const IconButton = React.forwardRef((props: IIconButton, ref: React.ForwardedRef
|
|||
'bg-white dark:bg-transparent': theme === 'seamless',
|
||||
'border border-solid bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500': theme === 'outlined',
|
||||
'border-transparent bg-primary-100 dark:bg-primary-800 hover:bg-primary-50 dark:hover:bg-primary-700 focus:bg-primary-100 dark:focus:bg-primary-800 text-primary-500 dark:text-primary-200': theme === 'secondary',
|
||||
'bg-gray-900 text-white': theme === 'dark',
|
||||
'opacity-50': filteredProps.disabled,
|
||||
}, className)}
|
||||
{...filteredProps}
|
||||
|
|
|
@ -17,11 +17,16 @@ interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
|
|||
src: string
|
||||
/** Width and height of the icon in pixels. */
|
||||
size?: number
|
||||
/** Override the data-testid */
|
||||
'data-testid'?: string
|
||||
}
|
||||
|
||||
/** Renders and SVG icon with optional counter. */
|
||||
const Icon: React.FC<IIcon> = ({ src, alt, count, size, countMax, ...filteredProps }): JSX.Element => (
|
||||
<div className='relative flex shrink-0 flex-col' data-testid='icon'>
|
||||
<div
|
||||
className='relative flex shrink-0 flex-col'
|
||||
data-testid={filteredProps['data-testid'] || 'icon'}
|
||||
>
|
||||
{count ? (
|
||||
<span className='absolute -right-3 -top-2 flex h-5 min-w-[20px] shrink-0 items-center justify-center whitespace-nowrap break-words'>
|
||||
<Counter count={count} countMax={countMax} />
|
||||
|
|
|
@ -16,6 +16,7 @@ const spaces = {
|
|||
};
|
||||
|
||||
const justifyContentOptions = {
|
||||
between: 'justify-between',
|
||||
center: 'justify-center',
|
||||
end: 'justify-end',
|
||||
};
|
||||
|
|
|
@ -14,6 +14,8 @@ interface UseEntityOpts<TEntity extends Entity> {
|
|||
schema?: EntitySchema<TEntity>
|
||||
/** Whether to refetch this entity every time the hook mounts, even if it's already in the store. */
|
||||
refetch?: boolean
|
||||
/** A flag to potentially disable sending requests to the API. */
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
function useEntity<TEntity extends Entity>(
|
||||
|
@ -31,6 +33,7 @@ function useEntity<TEntity extends Entity>(
|
|||
|
||||
const entity = useAppSelector(state => state.entities[entityType]?.store[entityId] as TEntity | undefined);
|
||||
|
||||
const isEnabled = opts.enabled ?? true;
|
||||
const isLoading = isFetching && !entity;
|
||||
|
||||
const fetchEntity = async () => {
|
||||
|
@ -44,10 +47,11 @@ function useEntity<TEntity extends Entity>(
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled) return;
|
||||
if (!entity || opts.refetch) {
|
||||
fetchEntity();
|
||||
}
|
||||
}, []);
|
||||
}, [isEnabled]);
|
||||
|
||||
return {
|
||||
entity,
|
||||
|
|
|
@ -30,6 +30,7 @@ import { queryClient } from 'soapbox/queries/client';
|
|||
import toast from 'soapbox/toast';
|
||||
import { Account } from 'soapbox/types/entities';
|
||||
import { isDefaultHeader, isLocal, isRemote } from 'soapbox/utils/accounts';
|
||||
import copy from 'soapbox/utils/copy';
|
||||
import { MASTODON, parseVersion } from 'soapbox/utils/features';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -44,6 +45,7 @@ const messages = defineMessages({
|
|||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
|
||||
report: { id: 'account.report', defaultMessage: 'Report @{name}' },
|
||||
copy: { id: 'account.copy', defaultMessage: 'Copy link to profile' },
|
||||
share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' },
|
||||
media: { id: 'account.media', defaultMessage: 'Media' },
|
||||
blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' },
|
||||
|
@ -273,6 +275,10 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
|||
});
|
||||
};
|
||||
|
||||
const handleCopy: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
copy(account.url);
|
||||
};
|
||||
|
||||
const makeMenu = () => {
|
||||
const menu: Menu = [];
|
||||
|
||||
|
@ -306,8 +312,22 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
|||
});
|
||||
}
|
||||
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.copy),
|
||||
action: handleCopy,
|
||||
icon: require('@tabler/icons/clipboard-copy.svg'),
|
||||
});
|
||||
|
||||
if (!ownAccount) return menu;
|
||||
|
||||
if (features.searchFromAccount) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(account.id === ownAccount.id ? messages.searchSelf : messages.search, { name: account.username }),
|
||||
action: onSearch,
|
||||
icon: require('@tabler/icons/search.svg'),
|
||||
});
|
||||
}
|
||||
|
||||
if (menu.length) {
|
||||
menu.push(null);
|
||||
}
|
||||
|
@ -323,13 +343,6 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
|||
to: '/settings',
|
||||
icon: require('@tabler/icons/settings.svg'),
|
||||
});
|
||||
if (features.searchFromAccount) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.searchSelf, { name: account.username }),
|
||||
action: onSearch,
|
||||
icon: require('@tabler/icons/search.svg'),
|
||||
});
|
||||
}
|
||||
menu.push(null);
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.mutes),
|
||||
|
@ -386,8 +399,6 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
|||
icon: require('@tabler/icons/user-check.svg'),
|
||||
});
|
||||
}
|
||||
|
||||
menu.push(null);
|
||||
} else if (features.lists && features.unrestrictedLists) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.add_or_remove_from_list),
|
||||
|
@ -396,13 +407,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
|||
});
|
||||
}
|
||||
|
||||
if (features.searchFromAccount) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.search, { name: account.username }),
|
||||
action: onSearch,
|
||||
icon: require('@tabler/icons/search.svg'),
|
||||
});
|
||||
}
|
||||
menu.push(null);
|
||||
|
||||
if (features.removeFromFollowers && account.relationship?.followed_by) {
|
||||
menu.push({
|
||||
|
|
|
@ -85,7 +85,7 @@ const Ad: React.FC<IAd> = ({ ad }) => {
|
|||
</Text>
|
||||
|
||||
<Icon
|
||||
className='h-5 w-5 stroke-accent-500'
|
||||
className='h-4 w-4 stroke-accent-500'
|
||||
src={require('@tabler/icons/timeline.svg')}
|
||||
/>
|
||||
</HStack>
|
||||
|
|
|
@ -31,6 +31,7 @@ import MarkdownButton from './markdown-button';
|
|||
import PollButton from './poll-button';
|
||||
import PollForm from './polls/poll-form';
|
||||
import PrivacyDropdown from './privacy-dropdown';
|
||||
import ReplyGroupIndicator from './reply-group-indicator';
|
||||
import ReplyMentions from './reply-mentions';
|
||||
import ScheduleButton from './schedule-button';
|
||||
import SpoilerButton from './spoiler-button';
|
||||
|
@ -295,6 +296,8 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
|||
|
||||
<WarningContainer composeId={id} />
|
||||
|
||||
{!shouldCondense && !event && !group && groupId && <ReplyGroupIndicator composeId={id} />}
|
||||
|
||||
{!shouldCondense && !event && !group && <ReplyIndicatorContainer composeId={id} />}
|
||||
|
||||
{!shouldCondense && !event && !group && <ReplyMentions composeId={id} />}
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import Link from 'soapbox/components/link';
|
||||
import { Text } from 'soapbox/components/ui';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { Group } from 'soapbox/schemas';
|
||||
import { makeGetStatus } from 'soapbox/selectors';
|
||||
|
||||
interface IReplyGroupIndicator {
|
||||
composeId: string
|
||||
}
|
||||
|
||||
const ReplyGroupIndicator = (props: IReplyGroupIndicator) => {
|
||||
const { composeId } = props;
|
||||
|
||||
const getStatus = useCallback(makeGetStatus(), []);
|
||||
|
||||
const status = useAppSelector((state) => getStatus(state, { id: state.compose.get(composeId)?.in_reply_to! }));
|
||||
const group = status?.group as Group;
|
||||
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text theme='muted' size='sm'>
|
||||
<FormattedMessage
|
||||
id='compose.reply_group_indicator.message'
|
||||
defaultMessage='Posting to {groupLink}'
|
||||
values={{
|
||||
groupLink: <Link
|
||||
to={`/group/${group.slug}`}
|
||||
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
|
||||
/>,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReplyGroupIndicator;
|
|
@ -1,9 +1,8 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { FormattedList, FormattedMessage } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { useAppSelector, useCompose, useFeatures } from 'soapbox/hooks';
|
||||
import { useAppDispatch, useAppSelector, useCompose, useFeatures } from 'soapbox/hooks';
|
||||
import { statusToMentionsAccountIdsArray } from 'soapbox/reducers/compose';
|
||||
import { makeGetStatus } from 'soapbox/selectors';
|
||||
import { isPubkey } from 'soapbox/utils/nostr';
|
||||
|
@ -15,7 +14,7 @@ interface IReplyMentions {
|
|||
}
|
||||
|
||||
const ReplyMentions: React.FC<IReplyMentions> = ({ composeId }) => {
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const features = useFeatures();
|
||||
const compose = useCompose(composeId);
|
||||
|
||||
|
|
|
@ -138,7 +138,7 @@ const Search = (props: ISearch) => {
|
|||
useEffect(() => {
|
||||
return () => {
|
||||
const newPath = history.location.pathname;
|
||||
const shouldPersistSearch = !!newPath.match(/@.+\/posts\/\d+/g)
|
||||
const shouldPersistSearch = !!newPath.match(/@.+\/posts\/[a-zA-Z0-9]+/g)
|
||||
|| !!newPath.match(/\/tags\/.+/g);
|
||||
|
||||
if (!shouldPersistSearch) {
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import React from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import CopyableInput from 'soapbox/components/copyable-input';
|
||||
import { Text, Icon, Stack, HStack } from 'soapbox/components/ui';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
import { getExplorerUrl } from '../utils/block-explorer';
|
||||
import { getTitle } from '../utils/coin-db';
|
||||
|
@ -19,7 +19,7 @@ export interface ICryptoAddress {
|
|||
const CryptoAddress: React.FC<ICryptoAddress> = (props): JSX.Element => {
|
||||
const { address, ticker, note } = props;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleModalClick = (e: React.MouseEvent<HTMLElement>): void => {
|
||||
dispatch(openModal('CRYPTO_DONATE', props));
|
||||
|
|
|
@ -15,7 +15,7 @@ import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
|||
import { makeGetStatus } from 'soapbox/selectors';
|
||||
|
||||
import ComposeForm from '../compose/components/compose-form';
|
||||
import { getDescendantsIds } from '../status';
|
||||
import { getDescendantsIds } from '../status/components/thread';
|
||||
import ThreadStatus from '../status/components/thread-status';
|
||||
|
||||
import type { VirtuosoHandle } from 'react-virtuoso';
|
||||
|
|
52
app/soapbox/features/followed_tags/index.tsx
Normal file
52
app/soapbox/features/followed_tags/index.tsx
Normal file
|
@ -0,0 +1,52 @@
|
|||
import debounce from 'lodash/debounce';
|
||||
import React, { useEffect } from 'react';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { fetchFollowedHashtags, expandFollowedHashtags } from 'soapbox/actions/tags';
|
||||
import Hashtag from 'soapbox/components/hashtag';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Column } from 'soapbox/components/ui';
|
||||
import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder-hashtag';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.followed_tags', defaultMessage: 'Followed hashtags' },
|
||||
});
|
||||
|
||||
const handleLoadMore = debounce((dispatch) => {
|
||||
dispatch(expandFollowedHashtags());
|
||||
}, 300, { leading: true });
|
||||
|
||||
const FollowedTags = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchFollowedHashtags());
|
||||
}, []);
|
||||
|
||||
const tags = useAppSelector((state => state.followed_tags.items));
|
||||
const isLoading = useAppSelector((state => state.followed_tags.isLoading));
|
||||
const hasMore = useAppSelector((state => !!state.followed_tags.next));
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.followed_tags' defaultMessage="You haven't followed any hashtag yet." />;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<ScrollableList
|
||||
scrollKey='followed_tags'
|
||||
emptyMessage={emptyMessage}
|
||||
isLoading={isLoading}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={() => handleLoadMore(dispatch)}
|
||||
placeholderComponent={PlaceholderHashtag}
|
||||
placeholderCount={5}
|
||||
itemClassName='pb-3'
|
||||
>
|
||||
{tags.map(tag => <Hashtag key={tag.name} hashtag={tag} />)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default FollowedTags;
|
|
@ -91,31 +91,32 @@ describe('<GroupTagListItem />', () => {
|
|||
expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('as a non-owner', () => {
|
||||
const group = buildGroup({
|
||||
relationship: buildGroupRelationship({
|
||||
role: GroupRoles.ADMIN,
|
||||
member: true,
|
||||
}),
|
||||
describe('as a non-owner', () => {
|
||||
const group = buildGroup({
|
||||
relationship: buildGroupRelationship({
|
||||
role: GroupRoles.ADMIN,
|
||||
member: true,
|
||||
}),
|
||||
});
|
||||
|
||||
describe('when the tag is pinned', () => {
|
||||
const tag = buildGroupTag({ pinned: true, visible: true });
|
||||
|
||||
it('does render the pin icon', () => {
|
||||
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
|
||||
screen.debug();
|
||||
expect(screen.queryAllByTestId('pin-icon')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the tag is visible', () => {
|
||||
const tag = buildGroupTag({ visible: true });
|
||||
describe('when the tag is not pinned', () => {
|
||||
const tag = buildGroupTag({ pinned: false, visible: true });
|
||||
|
||||
it('does not render the pin icon', () => {
|
||||
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
|
||||
expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the tag is not visible', () => {
|
||||
const tag = buildGroupTag({ visible: false });
|
||||
|
||||
it('does not render the pin icon', () => {
|
||||
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
|
||||
expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0);
|
||||
});
|
||||
it('does not render the pin icon', () => {
|
||||
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
|
||||
expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -55,6 +55,12 @@ const GroupActionButton = ({ group }: IGroupActionButton) => {
|
|||
: intl.formatMessage(messages.joinSuccess),
|
||||
);
|
||||
},
|
||||
onError(error) {
|
||||
const message = (error.response?.data as any).error;
|
||||
if (message) {
|
||||
toast.error(message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const onLeaveGroup = () =>
|
||||
|
|
|
@ -99,7 +99,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
|
|||
|
||||
if (!isDefaultHeader(group.header)) {
|
||||
header = (
|
||||
<a href={group.header} onClick={handleHeaderClick} target='_blank' className='relative'>
|
||||
<a href={group.header} onClick={handleHeaderClick} target='_blank' className='relative w-full'>
|
||||
{header}
|
||||
</a>
|
||||
);
|
||||
|
@ -155,6 +155,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
|
|||
theme='muted'
|
||||
align='center'
|
||||
dangerouslySetInnerHTML={{ __html: group.note_emojified }}
|
||||
className='[&_a]:text-primary-600 [&_a]:hover:underline [&_a]:dark:text-accent-blue'
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Text } from 'soapbox/components/ui';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
|
@ -11,17 +12,19 @@ interface IGroupMemberCount {
|
|||
|
||||
const GroupMemberCount = ({ group }: IGroupMemberCount) => {
|
||||
return (
|
||||
<Text theme='inherit' tag='span' size='sm' weight='medium' data-testid='group-member-count'>
|
||||
{shortNumberFormat(group.members_count)}
|
||||
{' '}
|
||||
<FormattedMessage
|
||||
id='groups.discover.search.results.member_count'
|
||||
defaultMessage='{members, plural, one {member} other {members}}'
|
||||
values={{
|
||||
members: group.members_count,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
<Link to={`/group/${group.slug}/members`} className='hover:underline'>
|
||||
<Text theme='inherit' tag='span' size='sm' weight='medium' data-testid='group-member-count'>
|
||||
{shortNumberFormat(group.members_count)}
|
||||
{' '}
|
||||
<FormattedMessage
|
||||
id='groups.discover.search.results.member_count'
|
||||
defaultMessage='{members, plural, one {member} other {members}}'
|
||||
values={{
|
||||
members: group.members_count,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@ const messages = defineMessages({
|
|||
leave: { id: 'group.leave.label', defaultMessage: 'Leave' },
|
||||
leaveSuccess: { id: 'group.leave.success', defaultMessage: 'Left the group' },
|
||||
report: { id: 'group.report.label', defaultMessage: 'Report' },
|
||||
share: { id: 'group.share.label', defaultMessage: 'Share' },
|
||||
});
|
||||
|
||||
interface IGroupActionButton {
|
||||
|
@ -35,6 +36,15 @@ const GroupOptionsButton = ({ group }: IGroupActionButton) => {
|
|||
const isAdmin = group.relationship?.role === GroupRoles.ADMIN;
|
||||
const isBlocked = group.relationship?.blocked_by;
|
||||
|
||||
const handleShare = () => {
|
||||
navigator.share({
|
||||
text: group.display_name,
|
||||
url: group.url,
|
||||
}).catch((e) => {
|
||||
if (e.name !== 'AbortError') console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
const onLeaveGroup = () =>
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.confirmationHeading),
|
||||
|
@ -49,6 +59,7 @@ const GroupOptionsButton = ({ group }: IGroupActionButton) => {
|
|||
}));
|
||||
|
||||
const menu: Menu = useMemo(() => {
|
||||
const canShare = 'share' in navigator;
|
||||
const items = [];
|
||||
|
||||
if (isMember || isAdmin) {
|
||||
|
@ -59,6 +70,14 @@ const GroupOptionsButton = ({ group }: IGroupActionButton) => {
|
|||
});
|
||||
}
|
||||
|
||||
if (canShare) {
|
||||
items.push({
|
||||
text: intl.formatMessage(messages.share),
|
||||
icon: require('@tabler/icons/share.svg'),
|
||||
action: handleShare,
|
||||
});
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
items.push({
|
||||
text: intl.formatMessage(messages.leave),
|
||||
|
|
|
@ -3,7 +3,7 @@ import { defineMessages, useIntl } from 'react-intl';
|
|||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useUpdateGroupTag } from 'soapbox/api/hooks';
|
||||
import { HStack, IconButton, Stack, Text, Tooltip } from 'soapbox/components/ui';
|
||||
import { HStack, Icon, IconButton, Stack, Text, Tooltip } from 'soapbox/components/ui';
|
||||
import { importEntities } from 'soapbox/entity-store/actions';
|
||||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
|
@ -84,6 +84,20 @@ const GroupTagListItem = (props: IGroupMemberListItem) => {
|
|||
};
|
||||
|
||||
const renderPinIcon = () => {
|
||||
if (!isOwner && tag.pinned) {
|
||||
return (
|
||||
<Icon
|
||||
src={require('@tabler/icons/pin-filled.svg')}
|
||||
className='h-5 w-5 text-gray-600'
|
||||
data-testid='pin-icon'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isOwner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isPinnable) {
|
||||
return (
|
||||
<Tooltip
|
||||
|
@ -149,12 +163,12 @@ const GroupTagListItem = (props: IGroupMemberListItem) => {
|
|||
</Stack>
|
||||
</Link>
|
||||
|
||||
{isOwner ? (
|
||||
<HStack alignItems='center' space={2}>
|
||||
{tag.visible ? (
|
||||
renderPinIcon()
|
||||
) : null}
|
||||
<HStack alignItems='center' space={2}>
|
||||
{tag.visible ? (
|
||||
renderPinIcon()
|
||||
) : null}
|
||||
|
||||
{isOwner ? (
|
||||
<Tooltip
|
||||
text={
|
||||
tag.visible ?
|
||||
|
@ -173,8 +187,8 @@ const GroupTagListItem = (props: IGroupMemberListItem) => {
|
|||
iconClassName='h-5 w-5 text-primary-500 dark:text-accent-blue'
|
||||
/>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
) : null}
|
||||
) : null}
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { Input, Streamfield } from 'soapbox/components/ui';
|
||||
|
@ -36,15 +36,19 @@ const GroupTagsField: React.FC<IGroupTagsField> = ({ tags, onChange, onAddItem,
|
|||
const HashtagField: StreamfieldComponent<string> = ({ value, onChange, autoFocus = false }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const formattedValue = useMemo(() => {
|
||||
return `#${value}`;
|
||||
}, [value]);
|
||||
|
||||
const handleChange: React.ChangeEventHandler<HTMLInputElement> = ({ target }) => {
|
||||
onChange(target.value);
|
||||
onChange(target.value.replace('#', ''));
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
outerClassName='w-full'
|
||||
type='text'
|
||||
value={value}
|
||||
value={formattedValue}
|
||||
onChange={handleChange}
|
||||
placeholder={intl.formatMessage(messages.hashtagPlaceholder)}
|
||||
autoFocus={autoFocus}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { useGroup, useUpdateGroup } from 'soapbox/api/hooks';
|
||||
import { useGroup, useGroupTags, useUpdateGroup } from 'soapbox/api/hooks';
|
||||
import { Button, Column, Form, FormActions, FormGroup, Icon, Input, Spinner, Textarea } from 'soapbox/components/ui';
|
||||
import { useAppSelector, useInstance } from 'soapbox/hooks';
|
||||
import { useImageField, useTextField } from 'soapbox/hooks/forms';
|
||||
|
@ -36,6 +36,7 @@ const EditGroup: React.FC<IEditGroup> = ({ params: { groupId } }) => {
|
|||
|
||||
const { group, isLoading } = useGroup(groupId);
|
||||
const { updateGroup } = useUpdateGroup(groupId);
|
||||
const { invalidate } = useGroupTags(groupId);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [tags, setTags] = useState<string[]>(['']);
|
||||
|
@ -64,6 +65,7 @@ const EditGroup: React.FC<IEditGroup> = ({ params: { groupId } }) => {
|
|||
tags,
|
||||
}, {
|
||||
onSuccess() {
|
||||
invalidate();
|
||||
toast.success(intl.formatMessage(messages.groupSaved));
|
||||
},
|
||||
onError(error) {
|
||||
|
|
|
@ -36,7 +36,7 @@ const GroupTopics: React.FC<IGroupTopics> = (props) => {
|
|||
showLoading={!group || isLoading && tags.length === 0}
|
||||
placeholderComponent={PlaceholderAccount}
|
||||
placeholderCount={3}
|
||||
className='divide-y divide-solid divide-gray-300'
|
||||
className='divide-y divide-solid divide-gray-300 dark:divide-gray-800'
|
||||
itemClassName='py-3 last:pb-0'
|
||||
emptyMessage={
|
||||
<Stack space={4} className='pt-6' justifyContent='center' alignItems='center'>
|
||||
|
|
|
@ -22,17 +22,18 @@ const GroupListItem = (props: IGroup) => {
|
|||
justifyContent='between'
|
||||
data-testid='group-list-item'
|
||||
>
|
||||
<Link key={group.id} to={`/group/${group.slug}`}>
|
||||
<Link key={group.id} to={`/group/${group.slug}`} className='overflow-hidden'>
|
||||
<HStack alignItems='center' space={2}>
|
||||
<GroupAvatar
|
||||
group={group}
|
||||
size={44}
|
||||
/>
|
||||
|
||||
<Stack>
|
||||
<Stack className='overflow-hidden'>
|
||||
<Text
|
||||
weight='bold'
|
||||
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
|
||||
truncate
|
||||
/>
|
||||
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={1} alignItems='center'>
|
||||
|
|
|
@ -19,7 +19,7 @@ const GroupLinkPreview: React.FC<IGroupLinkPreview> = ({ card }) => {
|
|||
return (
|
||||
<Stack className='cursor-default overflow-hidden rounded-lg border border-gray-300 text-center dark:border-gray-800'>
|
||||
<div
|
||||
className='-mb-8 h-32 w-full bg-center'
|
||||
className='-mb-8 h-32 w-full bg-cover bg-center'
|
||||
style={{ backgroundImage: `url(${group.header})` }}
|
||||
/>
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ import LayoutButtons, { GroupLayout } from './components/discover/layout-buttons
|
|||
import type { Group } from 'soapbox/schemas';
|
||||
|
||||
const messages = defineMessages({
|
||||
label: { id: 'groups.popular.label', defaultMessage: 'Suggested Groups' },
|
||||
label: { id: 'groups.suggested.label', defaultMessage: 'Suggested Groups' },
|
||||
});
|
||||
|
||||
const GridList: Components['List'] = React.forwardRef((props, ref) => {
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { connectHashtagStream } from 'soapbox/actions/streaming';
|
||||
import { fetchHashtag, followHashtag, unfollowHashtag } from 'soapbox/actions/tags';
|
||||
import { expandHashtagTimeline, clearTimeline } from 'soapbox/actions/timelines';
|
||||
import { Column } from 'soapbox/components/ui';
|
||||
import List, { ListItem } from 'soapbox/components/list';
|
||||
import { Column, Toggle } from 'soapbox/components/ui';
|
||||
import Timeline from 'soapbox/features/ui/components/timeline';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||
|
||||
import type { Tag as TagEntity } from 'soapbox/types/entities';
|
||||
|
||||
|
@ -32,9 +34,11 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
|
|||
const intl = useIntl();
|
||||
const id = params?.id || '';
|
||||
const tags = params?.tags || { any: [], all: [], none: [] };
|
||||
|
||||
|
||||
const features = useFeatures();
|
||||
const dispatch = useAppDispatch();
|
||||
const disconnects = useRef<(() => void)[]>([]);
|
||||
const tag = useAppSelector((state) => state.tags.get(id));
|
||||
|
||||
// Mastodon supports displaying results from multiple hashtags.
|
||||
// https://github.com/mastodon/mastodon/issues/6359
|
||||
|
@ -88,9 +92,18 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
|
|||
dispatch(expandHashtagTimeline(id, { maxId, tags }));
|
||||
};
|
||||
|
||||
const handleFollow = () => {
|
||||
if (tag?.following) {
|
||||
dispatch(unfollowHashtag(id));
|
||||
} else {
|
||||
dispatch(followHashtag(id));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
subscribe();
|
||||
dispatch(expandHashtagTimeline(id, { tags }));
|
||||
dispatch(fetchHashtag(id));
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
|
@ -105,7 +118,19 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
|
|||
}, [id]);
|
||||
|
||||
return (
|
||||
<Column label={title()} transparent>
|
||||
<Column bodyClassName='space-y-3' label={title()} transparent>
|
||||
{features.followHashtags && (
|
||||
<List>
|
||||
<ListItem
|
||||
label={<FormattedMessage id='hashtag.follow' defaultMessage='Follow hashtag' />}
|
||||
>
|
||||
<Toggle
|
||||
checked={tag?.following}
|
||||
onChange={handleFollow}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
)}
|
||||
<Timeline
|
||||
scrollKey='hashtag_timeline'
|
||||
timelineId={`hashtag:${id}`}
|
||||
|
@ -117,4 +142,4 @@ export const HashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default HashtagTimeline;
|
||||
export default HashtagTimeline;
|
||||
|
|
|
@ -4,7 +4,7 @@ import { HStack, Stack, Text } from 'soapbox/components/ui';
|
|||
|
||||
import { generateText, randomIntFromInterval } from '../utils';
|
||||
|
||||
export default () => {
|
||||
export default ({ withJoinAction = true }: { withJoinAction?: boolean }) => {
|
||||
const groupNameLength = randomIntFromInterval(12, 20);
|
||||
|
||||
return (
|
||||
|
@ -13,7 +13,7 @@ export default () => {
|
|||
justifyContent='between'
|
||||
className='animate-pulse'
|
||||
>
|
||||
<HStack alignItems='center' space={2}>
|
||||
<HStack alignItems='center' space={2} className='overflow-hidden'>
|
||||
{/* Group Avatar */}
|
||||
<div className='h-11 w-11 rounded-full bg-gray-500 dark:bg-gray-700 dark:ring-primary-900' />
|
||||
|
||||
|
@ -37,7 +37,9 @@ export default () => {
|
|||
</HStack>
|
||||
|
||||
{/* Join Group Button */}
|
||||
<div className='h-10 w-36 rounded-full bg-gray-300 dark:bg-gray-800' />
|
||||
{withJoinAction && (
|
||||
<div className='h-10 w-36 rounded-full bg-gray-300 dark:bg-gray-800' />
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
|
|||
import { useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { fetchMfa } from 'soapbox/actions/mfa';
|
||||
import { Card, CardBody, CardHeader, CardTitle, Column, Stack } from 'soapbox/components/ui';
|
||||
import { Column, Stack } from 'soapbox/components/ui';
|
||||
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
import DisableOtpForm from './mfa/disable-otp-form';
|
||||
|
@ -37,23 +37,15 @@ const MfaForm: React.FC = () => {
|
|||
const mfa = useAppSelector((state) => state.security.get('mfa'));
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} transparent withHeader={false}>
|
||||
<Card variant='rounded'>
|
||||
<CardHeader backHref='/settings'>
|
||||
<CardTitle title={intl.formatMessage(messages.heading)} />
|
||||
</CardHeader>
|
||||
|
||||
<CardBody>
|
||||
{mfa.getIn(['settings', 'totp']) ? (
|
||||
<DisableOtpForm />
|
||||
) : (
|
||||
<Stack space={4}>
|
||||
<EnableOtpForm displayOtpForm={displayOtpForm} handleSetupProceedClick={handleSetupProceedClick} />
|
||||
{displayOtpForm && <OtpConfirmForm />}
|
||||
</Stack>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
{mfa.getIn(['settings', 'totp']) ? (
|
||||
<DisableOtpForm />
|
||||
) : (
|
||||
<Stack space={4}>
|
||||
<EnableOtpForm displayOtpForm={displayOtpForm} handleSetupProceedClick={handleSetupProceedClick} />
|
||||
{displayOtpForm && <OtpConfirmForm />}
|
||||
</Stack>
|
||||
)}
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -15,15 +15,12 @@ import { getActualStatus } from 'soapbox/utils/status';
|
|||
|
||||
import StatusInteractionBar from './status-interaction-bar';
|
||||
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
import type { Attachment as AttachmentEntity, Group, Status as StatusEntity } from 'soapbox/types/entities';
|
||||
import type { Group, Status as StatusEntity } from 'soapbox/types/entities';
|
||||
|
||||
interface IDetailedStatus {
|
||||
status: StatusEntity
|
||||
onOpenMedia: (media: ImmutableList<AttachmentEntity>, index: number) => void
|
||||
onOpenVideo: (media: ImmutableList<AttachmentEntity>, start: number) => void
|
||||
onToggleHidden: (status: StatusEntity) => void
|
||||
showMedia: boolean
|
||||
showMedia?: boolean
|
||||
withMedia?: boolean
|
||||
onOpenCompareHistoryModal: (status: StatusEntity) => void
|
||||
onToggleMediaVisibility: () => void
|
||||
}
|
||||
|
@ -33,6 +30,7 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
|
|||
onOpenCompareHistoryModal,
|
||||
onToggleMediaVisibility,
|
||||
showMedia,
|
||||
withMedia = true,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
|
@ -155,7 +153,7 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
|
|||
|
||||
<TranslateButton status={actualStatus} />
|
||||
|
||||
{(quote || actualStatus.card || actualStatus.media_attachments.size > 0) && (
|
||||
{(withMedia && (quote || actualStatus.card || actualStatus.media_attachments.size > 0)) && (
|
||||
<Stack space={4}>
|
||||
<StatusMedia
|
||||
status={actualStatus}
|
||||
|
|
|
@ -2,12 +2,11 @@ import clsx from 'clsx';
|
|||
import { List as ImmutableList } from 'immutable';
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { HStack, Text, Emoji } from 'soapbox/components/ui';
|
||||
import { useAppSelector, useSoapboxConfig, useFeatures } from 'soapbox/hooks';
|
||||
import { useAppSelector, useSoapboxConfig, useFeatures, useAppDispatch } from 'soapbox/hooks';
|
||||
import { reduceEmoji } from 'soapbox/utils/emoji-reacts';
|
||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||
|
||||
|
@ -22,7 +21,7 @@ const StatusInteractionBar: React.FC<IStatusInteractionBar> = ({ status }): JSX.
|
|||
|
||||
const me = useAppSelector(({ me }) => me);
|
||||
const { allowedEmoji } = useSoapboxConfig();
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const features = useFeatures();
|
||||
const { account } = status;
|
||||
|
||||
|
|
468
app/soapbox/features/status/components/thread.tsx
Normal file
468
app/soapbox/features/status/components/thread.tsx
Normal file
|
@ -0,0 +1,468 @@
|
|||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import clsx from 'clsx';
|
||||
import { List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { type VirtuosoHandle } from 'react-virtuoso';
|
||||
|
||||
import { mentionCompose, replyCompose } from 'soapbox/actions/compose';
|
||||
import { favourite, reblog, unfavourite, unreblog } from 'soapbox/actions/interactions';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import { hideStatus, revealStatus } from 'soapbox/actions/statuses';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import StatusActionBar from 'soapbox/components/status-action-bar';
|
||||
import Tombstone from 'soapbox/components/tombstone';
|
||||
import { Stack } from 'soapbox/components/ui';
|
||||
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status';
|
||||
import PendingStatus from 'soapbox/features/ui/components/pending-status';
|
||||
import { useAppDispatch, useAppSelector, useOwnAccount, useSettings } from 'soapbox/hooks';
|
||||
import { RootState } from 'soapbox/store';
|
||||
import { type Account, type Status } from 'soapbox/types/entities';
|
||||
import { defaultMediaVisibility, textForScreenReader } from 'soapbox/utils/status';
|
||||
|
||||
import DetailedStatus from './detailed-status';
|
||||
import ThreadLoginCta from './thread-login-cta';
|
||||
import ThreadStatus from './thread-status';
|
||||
|
||||
type DisplayMedia = 'default' | 'hide_all' | 'show_all';
|
||||
|
||||
const getAncestorsIds = createSelector([
|
||||
(_: RootState, statusId: string | undefined) => statusId,
|
||||
(state: RootState) => state.contexts.inReplyTos,
|
||||
], (statusId, inReplyTos) => {
|
||||
let ancestorsIds = ImmutableOrderedSet<string>();
|
||||
let id: string | undefined = statusId;
|
||||
|
||||
while (id && !ancestorsIds.includes(id)) {
|
||||
ancestorsIds = ImmutableOrderedSet([id]).union(ancestorsIds);
|
||||
id = inReplyTos.get(id);
|
||||
}
|
||||
|
||||
return ancestorsIds;
|
||||
});
|
||||
|
||||
export const getDescendantsIds = createSelector([
|
||||
(_: RootState, statusId: string) => statusId,
|
||||
(state: RootState) => state.contexts.replies,
|
||||
], (statusId, contextReplies) => {
|
||||
let descendantsIds = ImmutableOrderedSet<string>();
|
||||
const ids = [statusId];
|
||||
|
||||
while (ids.length > 0) {
|
||||
const id = ids.shift();
|
||||
if (!id) break;
|
||||
|
||||
const replies = contextReplies.get(id);
|
||||
|
||||
if (descendantsIds.includes(id)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (statusId !== id) {
|
||||
descendantsIds = descendantsIds.union([id]);
|
||||
}
|
||||
|
||||
if (replies) {
|
||||
replies.reverse().forEach((reply: string) => {
|
||||
ids.unshift(reply);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return descendantsIds;
|
||||
});
|
||||
|
||||
interface IThread {
|
||||
status: Status
|
||||
withMedia?: boolean
|
||||
useWindowScroll?: boolean
|
||||
itemClassName?: string
|
||||
next: string | undefined
|
||||
handleLoadMore: () => void
|
||||
}
|
||||
|
||||
const Thread = (props: IThread) => {
|
||||
const {
|
||||
handleLoadMore,
|
||||
itemClassName,
|
||||
next,
|
||||
status,
|
||||
useWindowScroll = true,
|
||||
withMedia = true,
|
||||
} = props;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const history = useHistory();
|
||||
const intl = useIntl();
|
||||
const me = useOwnAccount();
|
||||
const settings = useSettings();
|
||||
|
||||
const displayMedia = settings.get('displayMedia') as DisplayMedia;
|
||||
const isUnderReview = status?.visibility === 'self';
|
||||
|
||||
const { ancestorsIds, descendantsIds } = useAppSelector((state) => {
|
||||
let ancestorsIds = ImmutableOrderedSet<string>();
|
||||
let descendantsIds = ImmutableOrderedSet<string>();
|
||||
|
||||
if (status) {
|
||||
const statusId = status.id;
|
||||
ancestorsIds = getAncestorsIds(state, state.contexts.inReplyTos.get(statusId));
|
||||
descendantsIds = getDescendantsIds(state, statusId);
|
||||
ancestorsIds = ancestorsIds.delete(statusId).subtract(descendantsIds);
|
||||
descendantsIds = descendantsIds.delete(statusId).subtract(ancestorsIds);
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
ancestorsIds,
|
||||
descendantsIds,
|
||||
};
|
||||
});
|
||||
|
||||
const [showMedia, setShowMedia] = useState<boolean>(status?.visibility === 'self' ? false : defaultMediaVisibility(status, displayMedia));
|
||||
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
const statusRef = useRef<HTMLDivElement>(null);
|
||||
const scroller = useRef<VirtuosoHandle>(null);
|
||||
|
||||
const handleToggleMediaVisibility = () => {
|
||||
setShowMedia(!showMedia);
|
||||
};
|
||||
|
||||
const handleHotkeyReact = () => {
|
||||
if (statusRef.current) {
|
||||
const firstEmoji: HTMLButtonElement | null = statusRef.current.querySelector('.emoji-react-selector .emoji-react-selector__emoji');
|
||||
firstEmoji?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFavouriteClick = (status: Status) => {
|
||||
if (status.favourited) {
|
||||
dispatch(unfavourite(status));
|
||||
} else {
|
||||
dispatch(favourite(status));
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplyClick = (status: Status) => dispatch(replyCompose(status));
|
||||
|
||||
const handleModalReblog = (status: Status) => dispatch(reblog(status));
|
||||
|
||||
const handleReblogClick = (status: Status, e?: React.MouseEvent) => {
|
||||
dispatch((_, getState) => {
|
||||
const boostModal = getSettings(getState()).get('boostModal');
|
||||
if (status.reblogged) {
|
||||
dispatch(unreblog(status));
|
||||
} else {
|
||||
if ((e && e.shiftKey) || !boostModal) {
|
||||
handleModalReblog(status);
|
||||
} else {
|
||||
dispatch(openModal('BOOST', { status, onReblog: handleModalReblog }));
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleMentionClick = (account: Account) => dispatch(mentionCompose(account));
|
||||
|
||||
const handleHotkeyOpenMedia = (e?: KeyboardEvent) => {
|
||||
const media = status?.media_attachments;
|
||||
|
||||
e?.preventDefault();
|
||||
|
||||
if (media && media.size) {
|
||||
const firstAttachment = media.first()!;
|
||||
|
||||
if (media.size === 1 && firstAttachment.type === 'video') {
|
||||
dispatch(openModal('VIDEO', { media: firstAttachment, status: status }));
|
||||
} else {
|
||||
dispatch(openModal('MEDIA', { media, index: 0, status: status }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleHidden = (status: Status) => {
|
||||
if (status.hidden) {
|
||||
dispatch(revealStatus(status.id));
|
||||
} else {
|
||||
dispatch(hideStatus(status.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleHotkeyMoveUp = () => {
|
||||
handleMoveUp(status!.id);
|
||||
};
|
||||
|
||||
const handleHotkeyMoveDown = () => {
|
||||
handleMoveDown(status!.id);
|
||||
};
|
||||
|
||||
const handleHotkeyReply = (e?: KeyboardEvent) => {
|
||||
e?.preventDefault();
|
||||
handleReplyClick(status!);
|
||||
};
|
||||
|
||||
const handleHotkeyFavourite = () => {
|
||||
handleFavouriteClick(status!);
|
||||
};
|
||||
|
||||
const handleHotkeyBoost = () => {
|
||||
handleReblogClick(status!);
|
||||
};
|
||||
|
||||
const handleHotkeyMention = (e?: KeyboardEvent) => {
|
||||
e?.preventDefault();
|
||||
const { account } = status!;
|
||||
if (!account || typeof account !== 'object') return;
|
||||
handleMentionClick(account);
|
||||
};
|
||||
|
||||
const handleHotkeyOpenProfile = () => {
|
||||
history.push(`/@${status!.getIn(['account', 'acct'])}`);
|
||||
};
|
||||
|
||||
const handleHotkeyToggleHidden = () => {
|
||||
handleToggleHidden(status!);
|
||||
};
|
||||
|
||||
const handleHotkeyToggleSensitive = () => {
|
||||
handleToggleMediaVisibility();
|
||||
};
|
||||
|
||||
const handleMoveUp = (id: string) => {
|
||||
if (id === status?.id) {
|
||||
_selectChild(ancestorsIds.size - 1);
|
||||
} else {
|
||||
let index = ImmutableList(ancestorsIds).indexOf(id);
|
||||
|
||||
if (index === -1) {
|
||||
index = ImmutableList(descendantsIds).indexOf(id);
|
||||
_selectChild(ancestorsIds.size + index);
|
||||
} else {
|
||||
_selectChild(index - 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveDown = (id: string) => {
|
||||
if (id === status?.id) {
|
||||
_selectChild(ancestorsIds.size + 1);
|
||||
} else {
|
||||
let index = ImmutableList(ancestorsIds).indexOf(id);
|
||||
|
||||
if (index === -1) {
|
||||
index = ImmutableList(descendantsIds).indexOf(id);
|
||||
_selectChild(ancestorsIds.size + index + 2);
|
||||
} else {
|
||||
_selectChild(index + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const _selectChild = (index: number) => {
|
||||
scroller.current?.scrollIntoView({
|
||||
index,
|
||||
behavior: 'smooth',
|
||||
done: () => {
|
||||
const element = document.querySelector<HTMLDivElement>(`#thread [data-index="${index}"] .focusable`);
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderTombstone = (id: string) => {
|
||||
return (
|
||||
<div className='py-4 pb-8'>
|
||||
<Tombstone
|
||||
key={id}
|
||||
id={id}
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStatus = (id: string) => {
|
||||
return (
|
||||
<ThreadStatus
|
||||
key={id}
|
||||
id={id}
|
||||
focusedStatusId={status!.id}
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
contextType='thread'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPendingStatus = (id: string) => {
|
||||
const idempotencyKey = id.replace(/^末pending-/, '');
|
||||
|
||||
return (
|
||||
<PendingStatus
|
||||
key={id}
|
||||
idempotencyKey={idempotencyKey}
|
||||
thread
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderChildren = (list: ImmutableOrderedSet<string>) => {
|
||||
return list.map(id => {
|
||||
if (id.endsWith('-tombstone')) {
|
||||
return renderTombstone(id);
|
||||
} else if (id.startsWith('末pending-')) {
|
||||
return renderPendingStatus(id);
|
||||
} else {
|
||||
return renderStatus(id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Reset media visibility if status changes.
|
||||
useEffect(() => {
|
||||
setShowMedia(status?.visibility === 'self' ? false : defaultMediaVisibility(status, displayMedia));
|
||||
}, [status.id]);
|
||||
|
||||
// Scroll focused status into view when thread updates.
|
||||
useEffect(() => {
|
||||
scroller.current?.scrollToIndex({
|
||||
index: ancestorsIds.size,
|
||||
offset: -146,
|
||||
});
|
||||
|
||||
setImmediate(() => statusRef.current?.querySelector<HTMLDivElement>('.detailed-actualStatus')?.focus());
|
||||
}, [status.id, ancestorsIds.size]);
|
||||
|
||||
const handleOpenCompareHistoryModal = (status: Status) => {
|
||||
dispatch(openModal('COMPARE_HISTORY', {
|
||||
statusId: status.id,
|
||||
}));
|
||||
};
|
||||
|
||||
const hasAncestors = ancestorsIds.size > 0;
|
||||
const hasDescendants = descendantsIds.size > 0;
|
||||
|
||||
type HotkeyHandlers = { [key: string]: (keyEvent?: KeyboardEvent) => void };
|
||||
|
||||
const handlers: HotkeyHandlers = {
|
||||
moveUp: handleHotkeyMoveUp,
|
||||
moveDown: handleHotkeyMoveDown,
|
||||
reply: handleHotkeyReply,
|
||||
favourite: handleHotkeyFavourite,
|
||||
boost: handleHotkeyBoost,
|
||||
mention: handleHotkeyMention,
|
||||
openProfile: handleHotkeyOpenProfile,
|
||||
toggleHidden: handleHotkeyToggleHidden,
|
||||
toggleSensitive: handleHotkeyToggleSensitive,
|
||||
openMedia: handleHotkeyOpenMedia,
|
||||
react: handleHotkeyReact,
|
||||
};
|
||||
|
||||
const focusedStatus = (
|
||||
<div className={clsx({ 'pb-4': hasDescendants })} key={status.id}>
|
||||
<HotKeys handlers={handlers}>
|
||||
<div
|
||||
ref={statusRef}
|
||||
className='focusable relative'
|
||||
tabIndex={0}
|
||||
// FIXME: no "reblogged by" text is added for the screen reader
|
||||
aria-label={textForScreenReader(intl, status)}
|
||||
>
|
||||
|
||||
<DetailedStatus
|
||||
status={status}
|
||||
showMedia={showMedia}
|
||||
withMedia={withMedia}
|
||||
onToggleMediaVisibility={handleToggleMediaVisibility}
|
||||
onOpenCompareHistoryModal={handleOpenCompareHistoryModal}
|
||||
/>
|
||||
|
||||
{!isUnderReview ? (
|
||||
<>
|
||||
<hr className='-mx-4 mb-2 max-w-[100vw] border-t-2 dark:border-primary-800' />
|
||||
|
||||
<StatusActionBar
|
||||
status={status}
|
||||
expandable={false}
|
||||
space='lg'
|
||||
withLabels
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</HotKeys>
|
||||
|
||||
{hasDescendants && (
|
||||
<hr className='-mx-4 mt-2 max-w-[100vw] border-t-2 dark:border-primary-800' />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const children: JSX.Element[] = [];
|
||||
|
||||
if (!useWindowScroll) {
|
||||
// Add padding to the top of the Thread (for Media Modal)
|
||||
children.push(<div className='h-4' />);
|
||||
}
|
||||
|
||||
if (hasAncestors) {
|
||||
children.push(...renderChildren(ancestorsIds).toArray());
|
||||
}
|
||||
|
||||
children.push(focusedStatus);
|
||||
|
||||
if (hasDescendants) {
|
||||
children.push(...renderChildren(descendantsIds).toArray());
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
space={2}
|
||||
className={
|
||||
clsx({
|
||||
'h-full': !useWindowScroll,
|
||||
'mt-2': useWindowScroll,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={node}
|
||||
className={
|
||||
clsx('thread', {
|
||||
'h-full': !useWindowScroll,
|
||||
})
|
||||
}
|
||||
>
|
||||
<ScrollableList
|
||||
id='thread'
|
||||
ref={scroller}
|
||||
hasMore={!!next}
|
||||
onLoadMore={handleLoadMore}
|
||||
placeholderComponent={() => <PlaceholderStatus variant='slim' />}
|
||||
initialTopMostItemIndex={ancestorsIds.size}
|
||||
useWindowScroll={useWindowScroll}
|
||||
itemClassName={itemClassName}
|
||||
className={
|
||||
clsx({
|
||||
'h-full': !useWindowScroll,
|
||||
})
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</ScrollableList>
|
||||
</div>
|
||||
|
||||
{!me && <ThreadLoginCta />}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Thread;
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useCallback } from 'react';
|
||||
|
||||
import QuotedStatus from 'soapbox/components/quoted-status';
|
||||
import Tombstone from 'soapbox/components/tombstone';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetStatus } from 'soapbox/selectors';
|
||||
|
||||
|
@ -18,6 +19,10 @@ const QuotedStatusContainer: React.FC<IQuotedStatusContainer> = ({ statusId }) =
|
|||
return null;
|
||||
}
|
||||
|
||||
if (status.tombstone) {
|
||||
return <Tombstone id={status.id} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<QuotedStatus
|
||||
status={status}
|
||||
|
|
|
@ -1,53 +1,20 @@
|
|||
import clsx from 'clsx';
|
||||
import { List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
import debounce from 'lodash/debounce';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { Redirect, useHistory } from 'react-router-dom';
|
||||
import { createSelector } from 'reselect';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
replyCompose,
|
||||
mentionCompose,
|
||||
} from 'soapbox/actions/compose';
|
||||
import {
|
||||
favourite,
|
||||
unfavourite,
|
||||
reblog,
|
||||
unreblog,
|
||||
} from 'soapbox/actions/interactions';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import {
|
||||
hideStatus,
|
||||
revealStatus,
|
||||
fetchStatusWithContext,
|
||||
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 StatusActionBar from 'soapbox/components/status-action-bar';
|
||||
import Tombstone from 'soapbox/components/tombstone';
|
||||
import { Column, Stack } from 'soapbox/components/ui';
|
||||
import { Column } from 'soapbox/components/ui';
|
||||
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status';
|
||||
import PendingStatus from 'soapbox/features/ui/components/pending-status';
|
||||
import { useAppDispatch, useAppSelector, useSettings } from 'soapbox/hooks';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetStatus } from 'soapbox/selectors';
|
||||
import { defaultMediaVisibility, textForScreenReader } from 'soapbox/utils/status';
|
||||
|
||||
import DetailedStatus from './components/detailed-status';
|
||||
import ThreadLoginCta from './components/thread-login-cta';
|
||||
import ThreadStatus from './components/thread-status';
|
||||
|
||||
import type { VirtuosoHandle } from 'react-virtuoso';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
import type {
|
||||
Account as AccountEntity,
|
||||
Attachment as AttachmentEntity,
|
||||
Status as StatusEntity,
|
||||
} from 'soapbox/types/entities';
|
||||
import Thread from './components/thread';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'status.title', defaultMessage: 'Post Details' },
|
||||
|
@ -67,106 +34,26 @@ const messages = defineMessages({
|
|||
blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' },
|
||||
});
|
||||
|
||||
const getAncestorsIds = createSelector([
|
||||
(_: RootState, statusId: string | undefined) => statusId,
|
||||
(state: RootState) => state.contexts.inReplyTos,
|
||||
], (statusId, inReplyTos) => {
|
||||
let ancestorsIds = ImmutableOrderedSet<string>();
|
||||
let id: string | undefined = statusId;
|
||||
|
||||
while (id && !ancestorsIds.includes(id)) {
|
||||
ancestorsIds = ImmutableOrderedSet([id]).union(ancestorsIds);
|
||||
id = inReplyTos.get(id);
|
||||
}
|
||||
|
||||
return ancestorsIds;
|
||||
});
|
||||
|
||||
export const getDescendantsIds = createSelector([
|
||||
(_: RootState, statusId: string) => statusId,
|
||||
(state: RootState) => state.contexts.replies,
|
||||
], (statusId, contextReplies) => {
|
||||
let descendantsIds = ImmutableOrderedSet<string>();
|
||||
const ids = [statusId];
|
||||
|
||||
while (ids.length > 0) {
|
||||
const id = ids.shift();
|
||||
if (!id) break;
|
||||
|
||||
const replies = contextReplies.get(id);
|
||||
|
||||
if (descendantsIds.includes(id)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (statusId !== id) {
|
||||
descendantsIds = descendantsIds.union([id]);
|
||||
}
|
||||
|
||||
if (replies) {
|
||||
replies.reverse().forEach((reply: string) => {
|
||||
ids.unshift(reply);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return descendantsIds;
|
||||
});
|
||||
|
||||
type DisplayMedia = 'default' | 'hide_all' | 'show_all';
|
||||
|
||||
type RouteParams = {
|
||||
statusId: string
|
||||
groupId?: string
|
||||
groupSlug?: string
|
||||
};
|
||||
|
||||
interface IThread {
|
||||
interface IStatusDetails {
|
||||
params: RouteParams
|
||||
onOpenMedia: (media: ImmutableList<AttachmentEntity>, index: number) => void
|
||||
onOpenVideo: (video: AttachmentEntity, time: number) => void
|
||||
}
|
||||
|
||||
const Thread: React.FC<IThread> = (props) => {
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
const StatusDetails: React.FC<IStatusDetails> = (props) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const settings = useSettings();
|
||||
const getStatus = useCallback(makeGetStatus(), []);
|
||||
const status = useAppSelector((state) => getStatus(state, { id: props.params.statusId }));
|
||||
|
||||
const me = useAppSelector(state => state.me);
|
||||
const status = useAppSelector(state => getStatus(state, { id: props.params.statusId }));
|
||||
const displayMedia = settings.get('displayMedia') as DisplayMedia;
|
||||
const isUnderReview = status?.visibility === 'self';
|
||||
|
||||
const { ancestorsIds, descendantsIds } = useAppSelector(state => {
|
||||
let ancestorsIds = ImmutableOrderedSet<string>();
|
||||
let descendantsIds = ImmutableOrderedSet<string>();
|
||||
|
||||
if (status) {
|
||||
const statusId = status.id;
|
||||
ancestorsIds = getAncestorsIds(state, state.contexts.inReplyTos.get(statusId));
|
||||
descendantsIds = getDescendantsIds(state, statusId);
|
||||
ancestorsIds = ancestorsIds.delete(statusId).subtract(descendantsIds);
|
||||
descendantsIds = descendantsIds.delete(statusId).subtract(ancestorsIds);
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
ancestorsIds,
|
||||
descendantsIds,
|
||||
};
|
||||
});
|
||||
|
||||
const [showMedia, setShowMedia] = useState<boolean>(status?.visibility === 'self' ? false : defaultMediaVisibility(status, displayMedia));
|
||||
const [isLoaded, setIsLoaded] = useState<boolean>(!!status);
|
||||
const [next, setNext] = useState<string>();
|
||||
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
const statusRef = useRef<HTMLDivElement>(null);
|
||||
const scroller = useRef<VirtuosoHandle>(null);
|
||||
|
||||
/** Fetch the status (and context) from the API. */
|
||||
const fetchData = async () => {
|
||||
const { params } = props;
|
||||
|
@ -179,241 +66,11 @@ const Thread: React.FC<IThread> = (props) => {
|
|||
useEffect(() => {
|
||||
fetchData().then(() => {
|
||||
setIsLoaded(true);
|
||||
}).catch(error => {
|
||||
}).catch(() => {
|
||||
setIsLoaded(true);
|
||||
});
|
||||
}, [props.params.statusId]);
|
||||
|
||||
const handleToggleMediaVisibility = () => {
|
||||
setShowMedia(!showMedia);
|
||||
};
|
||||
|
||||
const handleHotkeyReact = () => {
|
||||
if (statusRef.current) {
|
||||
const firstEmoji: HTMLButtonElement | null = statusRef.current.querySelector('.emoji-react-selector .emoji-react-selector__emoji');
|
||||
firstEmoji?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFavouriteClick = (status: StatusEntity) => {
|
||||
if (status.favourited) {
|
||||
dispatch(unfavourite(status));
|
||||
} else {
|
||||
dispatch(favourite(status));
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplyClick = (status: StatusEntity) => {
|
||||
dispatch(replyCompose(status));
|
||||
};
|
||||
|
||||
const handleModalReblog = (status: StatusEntity) => {
|
||||
dispatch(reblog(status));
|
||||
};
|
||||
|
||||
const handleReblogClick = (status: StatusEntity, e?: React.MouseEvent) => {
|
||||
dispatch((_, getState) => {
|
||||
const boostModal = getSettings(getState()).get('boostModal');
|
||||
if (status.reblogged) {
|
||||
dispatch(unreblog(status));
|
||||
} else {
|
||||
if ((e && e.shiftKey) || !boostModal) {
|
||||
handleModalReblog(status);
|
||||
} else {
|
||||
dispatch(openModal('BOOST', { status, onReblog: handleModalReblog }));
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleMentionClick = (account: AccountEntity) => {
|
||||
dispatch(mentionCompose(account));
|
||||
};
|
||||
|
||||
const handleOpenMedia = (media: ImmutableList<AttachmentEntity>, index: number) => {
|
||||
dispatch(openModal('MEDIA', { media, status, index }));
|
||||
};
|
||||
|
||||
const handleOpenVideo = (media: ImmutableList<AttachmentEntity>, time: number) => {
|
||||
dispatch(openModal('VIDEO', { media, time }));
|
||||
};
|
||||
|
||||
const handleHotkeyOpenMedia = (e?: KeyboardEvent) => {
|
||||
const { onOpenMedia, onOpenVideo } = props;
|
||||
const firstAttachment = status?.media_attachments.get(0);
|
||||
|
||||
e?.preventDefault();
|
||||
|
||||
if (status && firstAttachment) {
|
||||
if (firstAttachment.type === 'video') {
|
||||
onOpenVideo(firstAttachment, 0);
|
||||
} else {
|
||||
onOpenMedia(status.media_attachments, 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleHidden = (status: StatusEntity) => {
|
||||
if (status.hidden) {
|
||||
dispatch(revealStatus(status.id));
|
||||
} else {
|
||||
dispatch(hideStatus(status.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleHotkeyMoveUp = () => {
|
||||
handleMoveUp(status!.id);
|
||||
};
|
||||
|
||||
const handleHotkeyMoveDown = () => {
|
||||
handleMoveDown(status!.id);
|
||||
};
|
||||
|
||||
const handleHotkeyReply = (e?: KeyboardEvent) => {
|
||||
e?.preventDefault();
|
||||
handleReplyClick(status!);
|
||||
};
|
||||
|
||||
const handleHotkeyFavourite = () => {
|
||||
handleFavouriteClick(status!);
|
||||
};
|
||||
|
||||
const handleHotkeyBoost = () => {
|
||||
handleReblogClick(status!);
|
||||
};
|
||||
|
||||
const handleHotkeyMention = (e?: KeyboardEvent) => {
|
||||
e?.preventDefault();
|
||||
const { account } = status!;
|
||||
if (!account || typeof account !== 'object') return;
|
||||
handleMentionClick(account);
|
||||
};
|
||||
|
||||
const handleHotkeyOpenProfile = () => {
|
||||
history.push(`/@${status!.getIn(['account', 'acct'])}`);
|
||||
};
|
||||
|
||||
const handleHotkeyToggleHidden = () => {
|
||||
handleToggleHidden(status!);
|
||||
};
|
||||
|
||||
const handleHotkeyToggleSensitive = () => {
|
||||
handleToggleMediaVisibility();
|
||||
};
|
||||
|
||||
const handleMoveUp = (id: string) => {
|
||||
if (id === status?.id) {
|
||||
_selectChild(ancestorsIds.size - 1);
|
||||
} else {
|
||||
let index = ImmutableList(ancestorsIds).indexOf(id);
|
||||
|
||||
if (index === -1) {
|
||||
index = ImmutableList(descendantsIds).indexOf(id);
|
||||
_selectChild(ancestorsIds.size + index);
|
||||
} else {
|
||||
_selectChild(index - 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveDown = (id: string) => {
|
||||
if (id === status?.id) {
|
||||
_selectChild(ancestorsIds.size + 1);
|
||||
} else {
|
||||
let index = ImmutableList(ancestorsIds).indexOf(id);
|
||||
|
||||
if (index === -1) {
|
||||
index = ImmutableList(descendantsIds).indexOf(id);
|
||||
_selectChild(ancestorsIds.size + index + 2);
|
||||
} else {
|
||||
_selectChild(index + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const _selectChild = (index: number) => {
|
||||
scroller.current?.scrollIntoView({
|
||||
index,
|
||||
behavior: 'smooth',
|
||||
done: () => {
|
||||
const element = document.querySelector<HTMLDivElement>(`#thread [data-index="${index}"] .focusable`);
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderTombstone = (id: string) => {
|
||||
return (
|
||||
<div className='py-4 pb-8'>
|
||||
<Tombstone
|
||||
key={id}
|
||||
id={id}
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStatus = (id: string) => {
|
||||
return (
|
||||
<ThreadStatus
|
||||
key={id}
|
||||
id={id}
|
||||
focusedStatusId={status!.id}
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
contextType='thread'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPendingStatus = (id: string) => {
|
||||
const idempotencyKey = id.replace(/^末pending-/, '');
|
||||
|
||||
return (
|
||||
<PendingStatus
|
||||
key={id}
|
||||
idempotencyKey={idempotencyKey}
|
||||
thread
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderChildren = (list: ImmutableOrderedSet<string>) => {
|
||||
return list.map(id => {
|
||||
if (id.endsWith('-tombstone')) {
|
||||
return renderTombstone(id);
|
||||
} else if (id.startsWith('末pending-')) {
|
||||
return renderPendingStatus(id);
|
||||
} else {
|
||||
return renderStatus(id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Reset media visibility if status changes.
|
||||
useEffect(() => {
|
||||
setShowMedia(status?.visibility === 'self' ? false : defaultMediaVisibility(status, displayMedia));
|
||||
}, [status?.id]);
|
||||
|
||||
// Scroll focused status into view when thread updates.
|
||||
useEffect(() => {
|
||||
scroller.current?.scrollToIndex({
|
||||
index: ancestorsIds.size,
|
||||
offset: -146,
|
||||
});
|
||||
|
||||
setImmediate(() => statusRef.current?.querySelector<HTMLDivElement>('.detailed-actualStatus')?.focus());
|
||||
}, [props.params.statusId, status?.id, ancestorsIds.size, isLoaded]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
return fetchData();
|
||||
};
|
||||
|
||||
const handleLoadMore = useCallback(debounce(() => {
|
||||
if (next && status) {
|
||||
dispatch(fetchNext(status.id, next)).then(({ next }) => {
|
||||
|
@ -422,15 +79,10 @@ const Thread: React.FC<IThread> = (props) => {
|
|||
}
|
||||
}, 300, { leading: true }), [next, status]);
|
||||
|
||||
const handleOpenCompareHistoryModal = (status: StatusEntity) => {
|
||||
dispatch(openModal('COMPARE_HISTORY', {
|
||||
statusId: status.id,
|
||||
}));
|
||||
const handleRefresh = () => {
|
||||
return fetchData();
|
||||
};
|
||||
|
||||
const hasAncestors = ancestorsIds.size > 0;
|
||||
const hasDescendants = descendantsIds.size > 0;
|
||||
|
||||
if (status?.event) {
|
||||
return (
|
||||
<Redirect to={`/@${status.getIn(['account', 'acct'])}/events/${status.id}`} />
|
||||
|
@ -449,76 +101,6 @@ const Thread: React.FC<IThread> = (props) => {
|
|||
);
|
||||
}
|
||||
|
||||
type HotkeyHandlers = { [key: string]: (keyEvent?: KeyboardEvent) => void };
|
||||
|
||||
const handlers: HotkeyHandlers = {
|
||||
moveUp: handleHotkeyMoveUp,
|
||||
moveDown: handleHotkeyMoveDown,
|
||||
reply: handleHotkeyReply,
|
||||
favourite: handleHotkeyFavourite,
|
||||
boost: handleHotkeyBoost,
|
||||
mention: handleHotkeyMention,
|
||||
openProfile: handleHotkeyOpenProfile,
|
||||
toggleHidden: handleHotkeyToggleHidden,
|
||||
toggleSensitive: handleHotkeyToggleSensitive,
|
||||
openMedia: handleHotkeyOpenMedia,
|
||||
react: handleHotkeyReact,
|
||||
};
|
||||
|
||||
const focusedStatus = (
|
||||
<div className={clsx({ 'pb-4': hasDescendants })} key={status.id}>
|
||||
<HotKeys handlers={handlers}>
|
||||
<div
|
||||
ref={statusRef}
|
||||
className='focusable relative'
|
||||
tabIndex={0}
|
||||
// FIXME: no "reblogged by" text is added for the screen reader
|
||||
aria-label={textForScreenReader(intl, status)}
|
||||
>
|
||||
|
||||
<DetailedStatus
|
||||
status={status}
|
||||
onOpenVideo={handleOpenVideo}
|
||||
onOpenMedia={handleOpenMedia}
|
||||
onToggleHidden={handleToggleHidden}
|
||||
showMedia={showMedia}
|
||||
onToggleMediaVisibility={handleToggleMediaVisibility}
|
||||
onOpenCompareHistoryModal={handleOpenCompareHistoryModal}
|
||||
/>
|
||||
|
||||
{!isUnderReview ? (
|
||||
<>
|
||||
<hr className='-mx-4 mb-2 max-w-[100vw] border-t-2 dark:border-primary-800' />
|
||||
|
||||
<StatusActionBar
|
||||
status={status}
|
||||
expandable={false}
|
||||
space='expand'
|
||||
withLabels
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</HotKeys>
|
||||
|
||||
{hasDescendants && (
|
||||
<hr className='-mx-4 mt-2 max-w-[100vw] border-t-2 dark:border-primary-800' />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const children: JSX.Element[] = [];
|
||||
|
||||
if (hasAncestors) {
|
||||
children.push(...renderChildren(ancestorsIds).toArray());
|
||||
}
|
||||
|
||||
children.push(focusedStatus);
|
||||
|
||||
if (hasDescendants) {
|
||||
children.push(...renderChildren(descendantsIds).toArray());
|
||||
}
|
||||
|
||||
if (status.group && typeof status.group === 'object') {
|
||||
if (status.group.slug && !props.params.groupSlug) {
|
||||
return <Redirect to={`/group/${status.group.slug}/posts/${props.params.statusId}`} />;
|
||||
|
@ -533,25 +115,14 @@ const Thread: React.FC<IThread> = (props) => {
|
|||
return (
|
||||
<Column label={intl.formatMessage(titleMessage())}>
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<Stack space={2} className='mt-2'>
|
||||
<div ref={node} className='thread'>
|
||||
<ScrollableList
|
||||
id='thread'
|
||||
ref={scroller}
|
||||
hasMore={!!next}
|
||||
onLoadMore={handleLoadMore}
|
||||
placeholderComponent={() => <PlaceholderStatus variant='slim' />}
|
||||
initialTopMostItemIndex={ancestorsIds.size}
|
||||
>
|
||||
{children}
|
||||
</ScrollableList>
|
||||
</div>
|
||||
|
||||
{!me && <ThreadLoginCta />}
|
||||
</Stack>
|
||||
<Thread
|
||||
status={status}
|
||||
next={next}
|
||||
handleLoadMore={handleLoadMore}
|
||||
/>
|
||||
</PullToRefresh>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default Thread;
|
||||
export default StatusDetails;
|
||||
|
|
|
@ -5,7 +5,7 @@ import { Provider } from 'react-redux';
|
|||
import '@testing-library/jest-dom';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import { MODAL_OPEN } from 'soapbox/actions/modals';
|
||||
import { MODAL_CLOSE, MODAL_OPEN } from 'soapbox/actions/modals';
|
||||
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
|
||||
|
||||
import ComposeButton from '../compose-button';
|
||||
|
@ -35,6 +35,7 @@ describe('<ComposeButton />', () => {
|
|||
|
||||
expect(store.getActions().length).toEqual(0);
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(store.getActions()[0].type).toEqual(MODAL_OPEN);
|
||||
expect(store.getActions()[0].type).toEqual(MODAL_CLOSE);
|
||||
expect(store.getActions()[1].type).toEqual(MODAL_OPEN);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -10,8 +10,12 @@ import { useAppDispatch } from 'soapbox/hooks';
|
|||
|
||||
const ComposeButton = () => {
|
||||
const location = useLocation();
|
||||
const isOnGroupPage = location.pathname.startsWith('/group/');
|
||||
const match = useRouteMatch<{ groupSlug: string }>('/group/:groupSlug');
|
||||
const { entity: group } = useGroupLookup(match?.params.groupSlug || '');
|
||||
const isGroupMember = !!group?.relationship?.member;
|
||||
|
||||
if (location.pathname.startsWith('/group/')) {
|
||||
if (isOnGroupPage && isGroupMember) {
|
||||
return <GroupComposeButton />;
|
||||
}
|
||||
|
||||
|
|
|
@ -113,7 +113,7 @@ const HotkeysModal: React.FC<IHotkeysModal> = ({ onClose }) => {
|
|||
<TableCell><FormattedMessage id='keyboard_shortcuts.back' defaultMessage='to navigate back' /></TableCell>
|
||||
</tr>
|
||||
<tr>
|
||||
<TableCell><Hotkey>s</Hotkey></TableCell>
|
||||
<TableCell><Hotkey>s</Hotkey>, <Hotkey>/</Hotkey></TableCell>
|
||||
<TableCell><FormattedMessage id='keyboard_shortcuts.search' defaultMessage='to focus search' /></TableCell>
|
||||
</tr>
|
||||
<tr>
|
||||
|
|
|
@ -56,7 +56,7 @@ const ConfirmationStep: React.FC<IConfirmationStep> = ({ group }) => {
|
|||
<Text size='2xl' weight='bold' align='center'>{group.display_name}</Text>
|
||||
<Text
|
||||
size='md'
|
||||
className='mx-auto max-w-sm'
|
||||
className='mx-auto max-w-sm [&_a]:text-primary-600 [&_a]:hover:underline [&_a]:dark:text-accent-blue'
|
||||
dangerouslySetInnerHTML={{ __html: group.note_emojified }}
|
||||
/>
|
||||
</Stack>
|
||||
|
|
|
@ -1,15 +1,21 @@
|
|||
import clsx from 'clsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import ReactSwipeableViews from 'react-swipeable-views';
|
||||
|
||||
import { fetchNext, fetchStatusWithContext } from 'soapbox/actions/statuses';
|
||||
import ExtendedVideoPlayer from 'soapbox/components/extended-video-player';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import IconButton from 'soapbox/components/icon-button';
|
||||
import MissingIndicator from 'soapbox/components/missing-indicator';
|
||||
import StatusActionBar from 'soapbox/components/status-action-bar';
|
||||
import { Icon, IconButton, HStack, Stack } from 'soapbox/components/ui';
|
||||
import Audio from 'soapbox/features/audio';
|
||||
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status';
|
||||
import Thread from 'soapbox/features/status/components/thread';
|
||||
import Video from 'soapbox/features/video';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetStatus } from 'soapbox/selectors';
|
||||
|
||||
import ImageLoader from '../image-loader';
|
||||
|
||||
|
@ -18,16 +24,31 @@ import type { Attachment, Status } from 'soapbox/types/entities';
|
|||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
||||
expand: { id: 'lightbox.expand', defaultMessage: 'Expand' },
|
||||
minimize: { id: 'lightbox.minimize', defaultMessage: 'Minimize' },
|
||||
next: { id: 'lightbox.next', defaultMessage: 'Next' },
|
||||
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
||||
});
|
||||
|
||||
// you can't use 100vh, because the viewport height is taller
|
||||
// than the visible part of the document in some mobile
|
||||
// browsers when it's address bar is visible.
|
||||
// https://developers.google.com/web/updates/2016/12/url-bar-resizing
|
||||
const swipeableViewsStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
};
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
alignItems: 'center', // center vertically
|
||||
};
|
||||
|
||||
interface IMediaModal {
|
||||
media: ImmutableList<Attachment>
|
||||
status?: Status
|
||||
index: number
|
||||
time?: number
|
||||
onClose: () => void
|
||||
onClose(): void
|
||||
}
|
||||
|
||||
const MediaModal: React.FC<IMediaModal> = (props) => {
|
||||
|
@ -38,29 +59,24 @@ const MediaModal: React.FC<IMediaModal> = (props) => {
|
|||
time = 0,
|
||||
} = props;
|
||||
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
const dispatch = useAppDispatch();
|
||||
const history = useHistory();
|
||||
const intl = useIntl();
|
||||
|
||||
const getStatus = useCallback(makeGetStatus(), []);
|
||||
const actualStatus = useAppSelector((state) => getStatus(state, { id: status?.id as string }));
|
||||
|
||||
const [isLoaded, setIsLoaded] = useState<boolean>(!!status);
|
||||
const [next, setNext] = useState<string>();
|
||||
const [index, setIndex] = useState<number | null>(null);
|
||||
const [navigationHidden, setNavigationHidden] = useState(false);
|
||||
const [isFullScreen, setIsFullScreen] = useState(!status);
|
||||
|
||||
const handleSwipe = (index: number) => {
|
||||
setIndex(index % media.size);
|
||||
};
|
||||
const hasMultipleImages = media.size > 1;
|
||||
|
||||
const handleNextClick = () => {
|
||||
setIndex((getIndex() + 1) % media.size);
|
||||
};
|
||||
|
||||
const handlePrevClick = () => {
|
||||
setIndex((media.size + getIndex() - 1) % media.size);
|
||||
};
|
||||
|
||||
const handleChangeIndex: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
const index = Number(e.currentTarget.getAttribute('data-index'));
|
||||
setIndex(index % media.size);
|
||||
};
|
||||
const handleSwipe = (index: number) => setIndex(index % media.size);
|
||||
const handleNextClick = () => setIndex((getIndex() + 1) % media.size);
|
||||
const handlePrevClick = () => setIndex((media.size + getIndex() - 1) % media.size);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
|
@ -77,13 +93,10 @@ const MediaModal: React.FC<IMediaModal> = (props) => {
|
|||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown, false);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [index]);
|
||||
const handleDownload = () => {
|
||||
const mediaItem = hasMultipleImages ? media.get(index as number) : media.get(0);
|
||||
window.open(mediaItem?.url);
|
||||
};
|
||||
|
||||
const getIndex = () => index !== null ? index : props.index;
|
||||
|
||||
|
@ -105,61 +118,6 @@ const MediaModal: React.FC<IMediaModal> = (props) => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleCloserClick: React.MouseEventHandler = ({ target }) => {
|
||||
const whitelist = ['zoomable-image'];
|
||||
const activeSlide = document.querySelector('.media-modal .react-swipeable-view-container > div[aria-hidden="false"]');
|
||||
|
||||
const isClickOutside = target === activeSlide || !activeSlide?.contains(target as Element);
|
||||
const isWhitelisted = whitelist.some(w => (target as Element).classList.contains(w));
|
||||
|
||||
if (isClickOutside || isWhitelisted) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
let pagination: React.ReactNode[] = [];
|
||||
|
||||
const leftNav = media.size > 1 && (
|
||||
<button
|
||||
tabIndex={0}
|
||||
className='media-modal__nav media-modal__nav--left'
|
||||
onClick={handlePrevClick}
|
||||
aria-label={intl.formatMessage(messages.previous)}
|
||||
>
|
||||
<Icon src={require('@tabler/icons/arrow-left.svg')} />
|
||||
</button>
|
||||
);
|
||||
|
||||
const rightNav = media.size > 1 && (
|
||||
<button
|
||||
tabIndex={0}
|
||||
className='media-modal__nav media-modal__nav--right'
|
||||
onClick={handleNextClick}
|
||||
aria-label={intl.formatMessage(messages.next)}
|
||||
>
|
||||
<Icon src={require('@tabler/icons/arrow-right.svg')} />
|
||||
</button>
|
||||
);
|
||||
|
||||
if (media.size > 1) {
|
||||
pagination = media.toArray().map((item, i) => (
|
||||
<li className='media-modal__page-dot' key={i}>
|
||||
<button
|
||||
tabIndex={0}
|
||||
className={clsx('media-modal__button', {
|
||||
'media-modal__button--active': i === getIndex(),
|
||||
})}
|
||||
onClick={handleChangeIndex}
|
||||
data-index={i}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
</li>
|
||||
));
|
||||
}
|
||||
|
||||
const isMultiMedia = media.map((image) => image.type !== 'image').toArray();
|
||||
|
||||
const content = media.map((attachment, i) => {
|
||||
const width = (attachment.meta.getIn(['original', 'width']) || undefined) as number | undefined;
|
||||
const height = (attachment.meta.getIn(['original', 'height']) || undefined) as number | undefined;
|
||||
|
@ -230,62 +188,168 @@ const MediaModal: React.FC<IMediaModal> = (props) => {
|
|||
return null;
|
||||
}).toArray();
|
||||
|
||||
// you can't use 100vh, because the viewport height is taller
|
||||
// than the visible part of the document in some mobile
|
||||
// browsers when it's address bar is visible.
|
||||
// https://developers.google.com/web/updates/2016/12/url-bar-resizing
|
||||
const swipeableViewsStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
const handleLoadMore = useCallback(debounce(() => {
|
||||
if (next && status) {
|
||||
dispatch(fetchNext(status?.id, next)).then(({ next }) => {
|
||||
setNext(next);
|
||||
}).catch(() => { });
|
||||
}
|
||||
}, 300, { leading: true }), [next, status]);
|
||||
|
||||
/** Fetch the status (and context) from the API. */
|
||||
const fetchData = async () => {
|
||||
const { next } = await dispatch(fetchStatusWithContext(status?.id as string));
|
||||
setNext(next);
|
||||
};
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
alignItems: 'center', // center vertically
|
||||
};
|
||||
// Load data.
|
||||
useEffect(() => {
|
||||
fetchData().then(() => {
|
||||
setIsLoaded(true);
|
||||
}).catch(() => {
|
||||
setIsLoaded(true);
|
||||
});
|
||||
}, [status?.id]);
|
||||
|
||||
const navigationClassName = clsx('media-modal__navigation', {
|
||||
'media-modal__navigation--hidden': navigationHidden,
|
||||
});
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown, false);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [index]);
|
||||
|
||||
if (status) {
|
||||
if (!actualStatus && isLoaded) {
|
||||
return (
|
||||
<MissingIndicator />
|
||||
);
|
||||
} else if (!actualStatus) {
|
||||
return <PlaceholderStatus />;
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside: React.MouseEventHandler<HTMLElement> = (e) => {
|
||||
if ((e.target as HTMLElement).tagName === 'DIV') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal media-modal'>
|
||||
<div className='media-modal pointer-events-auto fixed inset-0 z-[9999] h-full bg-gray-900/90'>
|
||||
<div
|
||||
className='media-modal__closer'
|
||||
className='absolute inset-0'
|
||||
role='presentation'
|
||||
onClick={handleCloserClick}
|
||||
>
|
||||
<ReactSwipeableViews
|
||||
style={swipeableViewsStyle}
|
||||
containerStyle={containerStyle}
|
||||
onChangeIndex={handleSwipe}
|
||||
index={getIndex()}
|
||||
<Stack
|
||||
onClick={handleClickOutside}
|
||||
className={
|
||||
clsx('fixed inset-0 h-full grow transition-all', {
|
||||
'xl:pr-96': !isFullScreen,
|
||||
'xl:pr-0': isFullScreen,
|
||||
})
|
||||
}
|
||||
justifyContent='between'
|
||||
>
|
||||
{content}
|
||||
</ReactSwipeableViews>
|
||||
</div>
|
||||
<HStack alignItems='center' justifyContent='between' className='flex-[0_0_60px] p-4'>
|
||||
<IconButton
|
||||
title={intl.formatMessage(messages.close)}
|
||||
src={require('@tabler/icons/x.svg')}
|
||||
onClick={onClose}
|
||||
theme='dark'
|
||||
className='!p-1.5 hover:scale-105 hover:bg-gray-900'
|
||||
iconClassName='h-5 w-5'
|
||||
/>
|
||||
|
||||
<div className={navigationClassName}>
|
||||
<IconButton
|
||||
className='media-modal__close'
|
||||
title={intl.formatMessage(messages.close)}
|
||||
src={require('@tabler/icons/x.svg')}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<HStack alignItems='center' space={2}>
|
||||
<IconButton
|
||||
src={require('@tabler/icons/download.svg')}
|
||||
theme='dark'
|
||||
className='!p-1.5 hover:scale-105 hover:bg-gray-900'
|
||||
iconClassName='h-5 w-5'
|
||||
onClick={handleDownload}
|
||||
/>
|
||||
|
||||
{leftNav}
|
||||
{rightNav}
|
||||
{status && (
|
||||
<IconButton
|
||||
src={isFullScreen ? require('@tabler/icons/arrows-minimize.svg') : require('@tabler/icons/arrows-maximize.svg')}
|
||||
title={intl.formatMessage(isFullScreen ? messages.minimize : messages.expand)}
|
||||
theme='dark'
|
||||
className='hidden !p-1.5 hover:scale-105 hover:bg-gray-900 xl:block'
|
||||
iconClassName='h-5 w-5'
|
||||
onClick={() => setIsFullScreen(!isFullScreen)}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{(status && !isMultiMedia[getIndex()]) && (
|
||||
<div className={clsx('media-modal__meta', { 'media-modal__meta--shifted': media.size > 1 })}>
|
||||
<a href={status.url} onClick={handleStatusClick}>
|
||||
<FormattedMessage id='lightbox.view_context' defaultMessage='View context' />
|
||||
</a>
|
||||
{/* Height based on height of top/bottom bars */}
|
||||
<div className='relative h-[calc(100vh-120px)] w-full grow'>
|
||||
{hasMultipleImages && (
|
||||
<div className='absolute inset-y-0 left-5 z-10 flex items-center'>
|
||||
<button
|
||||
tabIndex={0}
|
||||
className='flex h-10 w-10 items-center justify-center rounded-full bg-gray-900 text-white'
|
||||
onClick={handlePrevClick}
|
||||
aria-label={intl.formatMessage(messages.previous)}
|
||||
>
|
||||
<Icon src={require('@tabler/icons/arrow-left.svg')} className='h-5 w-5' />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReactSwipeableViews
|
||||
style={swipeableViewsStyle}
|
||||
containerStyle={containerStyle}
|
||||
onChangeIndex={handleSwipe}
|
||||
index={getIndex()}
|
||||
>
|
||||
{content}
|
||||
</ReactSwipeableViews>
|
||||
|
||||
{hasMultipleImages && (
|
||||
<div className='absolute inset-y-0 right-5 z-10 flex items-center'>
|
||||
<button
|
||||
tabIndex={0}
|
||||
className='flex h-10 w-10 items-center justify-center rounded-full bg-gray-900 text-white'
|
||||
onClick={handleNextClick}
|
||||
aria-label={intl.formatMessage(messages.next)}
|
||||
>
|
||||
<Icon src={require('@tabler/icons/arrow-right.svg')} className='h-5 w-5' />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{actualStatus && (
|
||||
<HStack justifyContent='center' className='flex-[0_0_60px]'>
|
||||
<StatusActionBar
|
||||
status={actualStatus}
|
||||
space='md'
|
||||
statusActionButtonTheme='inverse'
|
||||
/>
|
||||
</HStack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{actualStatus && (
|
||||
<div
|
||||
className={
|
||||
clsx('-right-96 hidden bg-white transition-all xl:fixed xl:inset-y-0 xl:right-0 xl:flex xl:w-96 xl:flex-col', {
|
||||
'xl:!-right-96': isFullScreen,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Thread
|
||||
status={actualStatus}
|
||||
withMedia={false}
|
||||
useWindowScroll={false}
|
||||
itemClassName='px-4'
|
||||
next={next}
|
||||
handleLoadMore={handleLoadMore}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className='media-modal__pagination'>
|
||||
{pagination}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -19,7 +19,7 @@ const MyGroupsPanel = () => {
|
|||
>
|
||||
{isFetching ? (
|
||||
new Array(3).fill(0).map((_, idx) => (
|
||||
<PlaceholderGroupSearch key={idx} />
|
||||
<PlaceholderGroupSearch key={idx} withJoinAction={false} />
|
||||
))
|
||||
) : (
|
||||
groups.slice(0, 3).map((group) => (
|
||||
|
|
|
@ -19,7 +19,7 @@ const SuggestedGroupsPanel = () => {
|
|||
>
|
||||
{isFetching ? (
|
||||
new Array(3).fill(0).map((_, idx) => (
|
||||
<PlaceholderGroupSearch key={idx} />
|
||||
<PlaceholderGroupSearch key={idx} withJoinAction={false} />
|
||||
))
|
||||
) : (
|
||||
groups.slice(0, 3).map((group) => (
|
||||
|
|
|
@ -16,7 +16,7 @@ import { openModal } from 'soapbox/actions/modals';
|
|||
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 { connectNostrStream, connectUserStream } from 'soapbox/actions/streaming';
|
||||
import { fetchSuggestionsForTimeline } from 'soapbox/actions/suggestions';
|
||||
import { expandHomeTimeline } from 'soapbox/actions/timelines';
|
||||
import GroupLookupHoc from 'soapbox/components/hoc/group-lookup-hoc';
|
||||
|
@ -156,7 +156,7 @@ const EmptyPage = HomePage;
|
|||
const keyMap = {
|
||||
help: '?',
|
||||
new: 'n',
|
||||
search: 's',
|
||||
search: ['s', '/'],
|
||||
forceNew: 'option+n',
|
||||
reply: 'r',
|
||||
favourite: 'f',
|
||||
|
@ -391,7 +391,8 @@ const UI: React.FC<IUI> = ({ children }) => {
|
|||
const instance = useInstance();
|
||||
const statContext = useStatContext();
|
||||
|
||||
const disconnect = useRef<any>(null);
|
||||
const userStream = useRef<any>(null);
|
||||
const nostrStream = useRef<any>(null);
|
||||
const node = useRef<HTMLDivElement | null>(null);
|
||||
const hotkeys = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
|
@ -416,15 +417,24 @@ const UI: React.FC<IUI> = ({ children }) => {
|
|||
};
|
||||
|
||||
const connectStreaming = () => {
|
||||
if (!disconnect.current && accessToken && streamingUrl) {
|
||||
disconnect.current = dispatch(connectUserStream({ statContext }));
|
||||
if (accessToken && streamingUrl) {
|
||||
if (!userStream.current) {
|
||||
userStream.current = dispatch(connectUserStream({ statContext }));
|
||||
}
|
||||
if (!nostrStream.current && window.nostr) {
|
||||
nostrStream.current = dispatch(connectNostrStream());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const disconnectStreaming = () => {
|
||||
if (disconnect.current) {
|
||||
disconnect.current();
|
||||
disconnect.current = null;
|
||||
if (userStream.current) {
|
||||
userStream.current();
|
||||
userStream.current = null;
|
||||
}
|
||||
if (nostrStream.current) {
|
||||
nostrStream.current();
|
||||
nostrStream.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { normalizeStatus } from 'soapbox/normalizers';
|
||||
import {
|
||||
accountSchema,
|
||||
adSchema,
|
||||
|
@ -17,6 +18,7 @@ import {
|
|||
type GroupRelationship,
|
||||
type GroupTag,
|
||||
type Relationship,
|
||||
type Status,
|
||||
} from 'soapbox/schemas';
|
||||
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||
|
||||
|
@ -77,6 +79,12 @@ function buildRelationship(props: Partial<Relationship> = {}): Relationship {
|
|||
}, props));
|
||||
}
|
||||
|
||||
function buildStatus(props: Partial<Status> = {}) {
|
||||
return normalizeStatus(Object.assign({
|
||||
id: uuidv4(),
|
||||
}, props));
|
||||
}
|
||||
|
||||
export {
|
||||
buildAd,
|
||||
buildCard,
|
||||
|
@ -85,4 +93,5 @@ export {
|
|||
buildGroupRelationship,
|
||||
buildGroupTag,
|
||||
buildRelationship,
|
||||
buildStatus,
|
||||
};
|
|
@ -10,6 +10,7 @@
|
|||
"account.block_domain": "Hide everything from {domain}",
|
||||
"account.blocked": "Blocked",
|
||||
"account.chat": "Chat with @{name}",
|
||||
"account.copy": "Copy link to profile",
|
||||
"account.deactivated": "Deactivated",
|
||||
"account.direct": "Direct message @{name}",
|
||||
"account.domain_blocked": "Domain hidden",
|
||||
|
@ -387,6 +388,7 @@
|
|||
"compose.character_counter.title": "Used {chars} out of {maxChars} {maxChars, plural, one {character} other {characters}}",
|
||||
"compose.edit_success": "Your post was edited",
|
||||
"compose.invalid_schedule": "You must schedule a post at least 5 minutes out.",
|
||||
"compose.reply_group_indicator.message": "Posting to {groupLink}",
|
||||
"compose.submit_success": "Your post was sent!",
|
||||
"compose_event.create": "Create",
|
||||
"compose_event.edit_success": "Your event was edited",
|
||||
|
@ -805,6 +807,7 @@
|
|||
"group.report.label": "Report",
|
||||
"group.role.admin": "Admin",
|
||||
"group.role.owner": "Owner",
|
||||
"group.share.label": "Share",
|
||||
"group.tabs.all": "All",
|
||||
"group.tabs.media": "Media",
|
||||
"group.tabs.members": "Members",
|
||||
|
@ -852,10 +855,12 @@
|
|||
"groups.pending.label": "Pending Requests",
|
||||
"groups.popular.label": "Suggested Groups",
|
||||
"groups.search.placeholder": "Search My Groups",
|
||||
"groups.suggested.label": "Suggested Groups",
|
||||
"groups.tags.title": "Browse Topics",
|
||||
"hashtag.column_header.tag_mode.all": "and {additional}",
|
||||
"hashtag.column_header.tag_mode.any": "or {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "without {additional}",
|
||||
"hashtag.follow": "Follow hashtag",
|
||||
"header.home.label": "Home",
|
||||
"header.login.email.placeholder": "E-mail address",
|
||||
"header.login.forgot_password": "Forgot password?",
|
||||
|
@ -924,6 +929,8 @@
|
|||
"landing_page_modal.download": "Download",
|
||||
"landing_page_modal.helpCenter": "Help Center",
|
||||
"lightbox.close": "Cancel",
|
||||
"lightbox.expand": "Expand",
|
||||
"lightbox.minimize": "Minimize",
|
||||
"lightbox.next": "Next",
|
||||
"lightbox.previous": "Previous",
|
||||
"lightbox.view_context": "View context",
|
||||
|
|
|
@ -25,8 +25,19 @@ export const NotificationRecord = ImmutableRecord({
|
|||
total_count: null as number | null, // grouped notifications
|
||||
});
|
||||
|
||||
const normalizeType = (notification: ImmutableMap<string, any>) => {
|
||||
if (notification.get('type') === 'group_mention') {
|
||||
return notification.set('type', 'mention');
|
||||
}
|
||||
|
||||
return notification;
|
||||
};
|
||||
|
||||
export const normalizeNotification = (notification: Record<string, any>) => {
|
||||
return NotificationRecord(
|
||||
ImmutableMap(fromJS(notification)),
|
||||
ImmutableMap(fromJS(notification))
|
||||
.withMutations((notification: ImmutableMap<string, any>) => {
|
||||
normalizeType(notification);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
|
@ -133,7 +133,7 @@ type SoapboxConfigMap = ImmutableMap<string, any>;
|
|||
|
||||
const normalizeAds = (soapboxConfig: SoapboxConfigMap): SoapboxConfigMap => {
|
||||
if (soapboxConfig.has('ads')) {
|
||||
const ads = filteredArray(adSchema).parse(soapboxConfig.get('ads').toJS());
|
||||
const ads = filteredArray(adSchema).parse(ImmutableList(soapboxConfig.get('ads')).toJS());
|
||||
return soapboxConfig.set('ads', ads);
|
||||
} else {
|
||||
return soapboxConfig;
|
||||
|
|
|
@ -19,6 +19,7 @@ export const TagRecord = ImmutableRecord({
|
|||
name: '',
|
||||
url: '',
|
||||
history: null as ImmutableList<History> | null,
|
||||
following: false,
|
||||
});
|
||||
|
||||
const normalizeHistoryList = (tag: ImmutableMap<string, any>) => {
|
||||
|
|
47
app/soapbox/reducers/followed_tags.ts
Normal file
47
app/soapbox/reducers/followed_tags.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { List as ImmutableList, Record as ImmutableRecord } from 'immutable';
|
||||
|
||||
import {
|
||||
FOLLOWED_HASHTAGS_FETCH_REQUEST,
|
||||
FOLLOWED_HASHTAGS_FETCH_SUCCESS,
|
||||
FOLLOWED_HASHTAGS_FETCH_FAIL,
|
||||
FOLLOWED_HASHTAGS_EXPAND_REQUEST,
|
||||
FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
|
||||
FOLLOWED_HASHTAGS_EXPAND_FAIL,
|
||||
} from 'soapbox/actions/tags';
|
||||
import { normalizeTag } from 'soapbox/normalizers';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity, Tag } from 'soapbox/types/entities';
|
||||
|
||||
const ReducerRecord = ImmutableRecord({
|
||||
items: ImmutableList<Tag>(),
|
||||
isLoading: false,
|
||||
next: null,
|
||||
});
|
||||
|
||||
export default function followed_tags(state = ReducerRecord(), action: AnyAction) {
|
||||
switch (action.type) {
|
||||
case FOLLOWED_HASHTAGS_FETCH_REQUEST:
|
||||
return state.set('isLoading', true);
|
||||
case FOLLOWED_HASHTAGS_FETCH_SUCCESS:
|
||||
return state.withMutations(map => {
|
||||
map.set('items', ImmutableList(action.followed_tags.map((item: APIEntity) => normalizeTag(item))));
|
||||
map.set('isLoading', false);
|
||||
map.set('next', action.next);
|
||||
});
|
||||
case FOLLOWED_HASHTAGS_FETCH_FAIL:
|
||||
return state.set('isLoading', false);
|
||||
case FOLLOWED_HASHTAGS_EXPAND_REQUEST:
|
||||
return state.set('isLoading', true);
|
||||
case FOLLOWED_HASHTAGS_EXPAND_SUCCESS:
|
||||
return state.withMutations(map => {
|
||||
map.update('items', list => list.concat(action.followed_tags.map((item: APIEntity) => normalizeTag(item))));
|
||||
map.set('isLoading', false);
|
||||
map.set('next', action.next);
|
||||
});
|
||||
case FOLLOWED_HASHTAGS_EXPAND_FAIL:
|
||||
return state.set('isLoading', false);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -28,6 +28,7 @@ import custom_emojis from './custom-emojis';
|
|||
import domain_lists from './domain-lists';
|
||||
import dropdown_menu from './dropdown-menu';
|
||||
import filters from './filters';
|
||||
import followed_tags from './followed_tags';
|
||||
import group_memberships from './group-memberships';
|
||||
import group_relationships from './group-relationships';
|
||||
import groups from './groups';
|
||||
|
@ -61,6 +62,7 @@ import status_hover_card from './status-hover-card';
|
|||
import status_lists from './status-lists';
|
||||
import statuses from './statuses';
|
||||
import suggestions from './suggestions';
|
||||
import tags from './tags';
|
||||
import timelines from './timelines';
|
||||
import trending_statuses from './trending-statuses';
|
||||
import trends from './trends';
|
||||
|
@ -92,6 +94,7 @@ const reducers = {
|
|||
dropdown_menu,
|
||||
entities,
|
||||
filters,
|
||||
followed_tags,
|
||||
group_memberships,
|
||||
group_relationships,
|
||||
groups,
|
||||
|
@ -125,6 +128,7 @@ const reducers = {
|
|||
status_lists,
|
||||
statuses,
|
||||
suggestions,
|
||||
tags,
|
||||
timelines,
|
||||
trending_statuses,
|
||||
trends,
|
||||
|
|
|
@ -88,12 +88,12 @@ const isValid = (notification: APIEntity) => {
|
|||
}
|
||||
|
||||
// https://gitlab.com/soapbox-pub/soapbox/-/issues/424
|
||||
if (!notification.account.id) {
|
||||
if (!notification.account.get('id')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mastodon can return status notifications with a null status
|
||||
if (['mention', 'reblog', 'favourite', 'poll', 'status'].includes(notification.type) && !notification.status.id) {
|
||||
if (['mention', 'reblog', 'favourite', 'poll', 'status'].includes(notification.type) && !notification.status.get('id')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -131,6 +131,7 @@ const importNotification = (state: State, notification: APIEntity) => {
|
|||
export const processRawNotifications = (notifications: APIEntity[]) => (
|
||||
ImmutableOrderedMap(
|
||||
notifications
|
||||
.map(normalizeNotification)
|
||||
.filter(isValid)
|
||||
.map(n => [n.id, fixNotification(n)]),
|
||||
));
|
||||
|
|
30
app/soapbox/reducers/tags.ts
Normal file
30
app/soapbox/reducers/tags.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import {
|
||||
HASHTAG_FETCH_SUCCESS,
|
||||
HASHTAG_FOLLOW_REQUEST,
|
||||
HASHTAG_FOLLOW_FAIL,
|
||||
HASHTAG_UNFOLLOW_REQUEST,
|
||||
HASHTAG_UNFOLLOW_FAIL,
|
||||
} from 'soapbox/actions/tags';
|
||||
import { normalizeTag } from 'soapbox/normalizers';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { Tag } from 'soapbox/types/entities';
|
||||
|
||||
const initialState = ImmutableMap<string, Tag>();
|
||||
|
||||
export default function tags(state = initialState, action: AnyAction) {
|
||||
switch (action.type) {
|
||||
case HASHTAG_FETCH_SUCCESS:
|
||||
return state.set(action.name, normalizeTag(action.tag));
|
||||
case HASHTAG_FOLLOW_REQUEST:
|
||||
case HASHTAG_UNFOLLOW_FAIL:
|
||||
return state.setIn([action.name, 'following'], true);
|
||||
case HASHTAG_FOLLOW_FAIL:
|
||||
case HASHTAG_UNFOLLOW_REQUEST:
|
||||
return state.setIn([action.name, 'following'], false);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -49,7 +49,7 @@ const accountSchema = z.object({
|
|||
verified: z.boolean().default(false),
|
||||
website: z.string().catch(''),
|
||||
|
||||
/**
|
||||
/*
|
||||
* Internal fields
|
||||
*/
|
||||
display_name_html: z.string().catch(''),
|
||||
|
@ -57,7 +57,7 @@ const accountSchema = z.object({
|
|||
note_emojified: z.string().catch(''),
|
||||
relationship: relationshipSchema.nullable().catch(null),
|
||||
|
||||
/**
|
||||
/*
|
||||
* Misc
|
||||
*/
|
||||
other_settings: z.any(),
|
||||
|
@ -99,7 +99,7 @@ const accountSchema = z.object({
|
|||
// Notes
|
||||
account.note_emojified = emojify(account.note, customEmojiMap);
|
||||
|
||||
/**
|
||||
/*
|
||||
* Todo
|
||||
* - internal fields
|
||||
* - donor
|
||||
|
|
|
@ -62,6 +62,12 @@ const audioAttachmentSchema = baseAttachmentSchema.extend({
|
|||
type: z.literal('audio'),
|
||||
meta: z.object({
|
||||
duration: z.number().optional().catch(undefined),
|
||||
colors: z.object({
|
||||
background: z.string().optional().catch(undefined),
|
||||
foreground: z.string().optional().catch(undefined),
|
||||
accent: z.string().optional().catch(undefined),
|
||||
duration: z.number().optional().catch(undefined),
|
||||
}).optional().catch(undefined),
|
||||
}).catch({}),
|
||||
});
|
||||
|
||||
|
|
20
app/soapbox/schemas/event.ts
Normal file
20
app/soapbox/schemas/event.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { attachmentSchema } from './attachment';
|
||||
import { locationSchema } from './location';
|
||||
|
||||
const eventSchema = z.object({
|
||||
name: z.string().catch(''),
|
||||
start_time: z.string().datetime().nullable().catch(null),
|
||||
end_time: z.string().datetime().nullable().catch(null),
|
||||
join_mode: z.enum(['free', 'restricted', 'invite']).nullable().catch(null),
|
||||
participants_count: z.number().catch(0),
|
||||
location: locationSchema.nullable().catch(null),
|
||||
join_state: z.enum(['pending', 'reject', 'accept']).nullable().catch(null),
|
||||
banner: attachmentSchema.nullable().catch(null),
|
||||
links: z.array(attachmentSchema).nullable().catch(null),
|
||||
});
|
||||
|
||||
type Event = z.infer<typeof eventSchema>;
|
||||
|
||||
export { eventSchema, type Event };
|
23
app/soapbox/schemas/location.ts
Normal file
23
app/soapbox/schemas/location.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
const locationSchema = z.object({
|
||||
url: z.string().url().catch(''),
|
||||
description: z.string().catch(''),
|
||||
country: z.string().catch(''),
|
||||
locality: z.string().catch(''),
|
||||
region: z.string().catch(''),
|
||||
postal_code: z.string().catch(''),
|
||||
street: z.string().catch(''),
|
||||
origin_id: z.string().catch(''),
|
||||
origin_provider: z.string().catch(''),
|
||||
type: z.string().catch(''),
|
||||
timezone: z.string().catch(''),
|
||||
geom: z.object({
|
||||
coordinates: z.tuple([z.number(), z.number()]).nullable().catch(null),
|
||||
srid: z.string().catch(''),
|
||||
}).nullable().catch(null),
|
||||
});
|
||||
|
||||
type Location = z.infer<typeof locationSchema>;
|
||||
|
||||
export { locationSchema, type Location };
|
|
@ -1,18 +1,19 @@
|
|||
import escapeTextContentForBrowser from 'escape-html';
|
||||
import { z } from 'zod';
|
||||
|
||||
import emojify from 'soapbox/features/emoji';
|
||||
import { stripCompatibilityFeatures, unescapeHTML } from 'soapbox/utils/html';
|
||||
|
||||
import { accountSchema } from './account';
|
||||
import { attachmentSchema } from './attachment';
|
||||
import { cardSchema } from './card';
|
||||
import { customEmojiSchema } from './custom-emoji';
|
||||
import { eventSchema } from './event';
|
||||
import { groupSchema } from './group';
|
||||
import { mentionSchema } from './mention';
|
||||
import { pollSchema } from './poll';
|
||||
import { tagSchema } from './tag';
|
||||
import { contentSchema, dateSchema, filteredArray } from './utils';
|
||||
|
||||
const tombstoneSchema = z.object({
|
||||
reason: z.enum(['deleted']),
|
||||
});
|
||||
import { contentSchema, dateSchema, filteredArray, makeCustomEmojiMap } from './utils';
|
||||
|
||||
const baseStatusSchema = z.object({
|
||||
account: accountSchema,
|
||||
|
@ -39,27 +40,97 @@ const baseStatusSchema = z.object({
|
|||
mentions: filteredArray(mentionSchema),
|
||||
muted: z.coerce.boolean(),
|
||||
pinned: z.coerce.boolean(),
|
||||
pleroma: z.object({}).optional().catch(undefined),
|
||||
pleroma: z.object({
|
||||
quote_visible: z.boolean().catch(true),
|
||||
}).optional().catch(undefined),
|
||||
poll: pollSchema.nullable().catch(null),
|
||||
quote: z.literal(null).catch(null),
|
||||
quotes_count: z.number().catch(0),
|
||||
reblog: z.literal(null).catch(null),
|
||||
reblogged: z.coerce.boolean(),
|
||||
reblogs_count: z.number().catch(0),
|
||||
replies_count: z.number().catch(0),
|
||||
sensitive: z.coerce.boolean(),
|
||||
spoiler_text: contentSchema,
|
||||
tags: filteredArray(tagSchema),
|
||||
tombstone: tombstoneSchema.nullable().optional(),
|
||||
tombstone: z.object({
|
||||
reason: z.enum(['deleted']),
|
||||
}).nullable().optional().catch(undefined),
|
||||
uri: z.string().url().catch(''),
|
||||
url: z.string().url().catch(''),
|
||||
visibility: z.string().catch('public'),
|
||||
});
|
||||
|
||||
type BaseStatus = z.infer<typeof baseStatusSchema>;
|
||||
type TransformableStatus = Omit<BaseStatus, 'reblog' | 'quote' | 'pleroma'>;
|
||||
|
||||
/** Creates search index from the status. */
|
||||
const buildSearchIndex = (status: TransformableStatus): string => {
|
||||
const pollOptionTitles = status.poll ? status.poll.options.map(({ title }) => title) : [];
|
||||
const mentionedUsernames = status.mentions.map(({ acct }) => `@${acct}`);
|
||||
|
||||
const fields = [
|
||||
status.spoiler_text,
|
||||
status.content,
|
||||
...pollOptionTitles,
|
||||
...mentionedUsernames,
|
||||
];
|
||||
|
||||
const searchContent = unescapeHTML(fields.join('\n\n')) || '';
|
||||
return new DOMParser().parseFromString(searchContent, 'text/html').documentElement.textContent || '';
|
||||
};
|
||||
|
||||
type Translation = {
|
||||
content: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
/** Add internal fields to the status. */
|
||||
const transformStatus = <T extends TransformableStatus>(status: T) => {
|
||||
const emojiMap = makeCustomEmojiMap(status.emojis);
|
||||
|
||||
const contentHtml = stripCompatibilityFeatures(emojify(status.content, emojiMap));
|
||||
const spoilerHtml = emojify(escapeTextContentForBrowser(status.spoiler_text), emojiMap);
|
||||
|
||||
return {
|
||||
...status,
|
||||
contentHtml,
|
||||
spoilerHtml,
|
||||
search_index: buildSearchIndex(status),
|
||||
hidden: false,
|
||||
filtered: [],
|
||||
showFiltered: false, // TODO: this should be removed from the schema and done somewhere else
|
||||
approval_status: 'approval' as const,
|
||||
translation: undefined as Translation | undefined,
|
||||
expectsCard: false,
|
||||
};
|
||||
};
|
||||
|
||||
const embeddedStatusSchema = baseStatusSchema
|
||||
.transform(transformStatus)
|
||||
.nullable()
|
||||
.catch(null);
|
||||
|
||||
const statusSchema = baseStatusSchema.extend({
|
||||
quote: baseStatusSchema.nullable().catch(null),
|
||||
reblog: baseStatusSchema.nullable().catch(null),
|
||||
});
|
||||
quote: embeddedStatusSchema,
|
||||
reblog: embeddedStatusSchema,
|
||||
pleroma: z.object({
|
||||
event: eventSchema,
|
||||
quote: embeddedStatusSchema,
|
||||
quote_visible: z.boolean().catch(true),
|
||||
}).optional().catch(undefined),
|
||||
}).transform(({ pleroma, ...status }) => {
|
||||
return {
|
||||
...status,
|
||||
event: pleroma?.event,
|
||||
quote: pleroma?.quote || status.quote || null,
|
||||
// There's apparently no better way to do this...
|
||||
// Just trying to remove the `event` and `quote` keys from the object.
|
||||
pleroma: pleroma ? (() => {
|
||||
const { event, quote, ...rest } = pleroma;
|
||||
return rest;
|
||||
})() : undefined,
|
||||
};
|
||||
}).transform(transformStatus);
|
||||
|
||||
type Status = z.infer<typeof statusSchema>;
|
||||
|
||||
|
|
|
@ -8,10 +8,18 @@ import type { AppDispatch, RootState } from 'soapbox/store';
|
|||
|
||||
const randomIntUpTo = (max: number) => Math.floor(Math.random() * Math.floor(max));
|
||||
|
||||
interface ConnectStreamCallbacks {
|
||||
onConnect(): void
|
||||
onDisconnect(): void
|
||||
onReceive(websocket: WebSocket, data: unknown): void
|
||||
}
|
||||
|
||||
type PollingRefreshFn = (dispatch: AppDispatch, done?: () => void) => void
|
||||
|
||||
export function connectStream(
|
||||
path: string,
|
||||
pollingRefresh: ((dispatch: AppDispatch, done?: () => void) => void) | null = null,
|
||||
callbacks: (dispatch: AppDispatch, getState: () => RootState) => Record<string, any> = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} }),
|
||||
pollingRefresh: PollingRefreshFn | null = null,
|
||||
callbacks: (dispatch: AppDispatch, getState: () => RootState) => ConnectStreamCallbacks,
|
||||
) {
|
||||
return (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const streamingAPIBaseURL = getState().instance.urls.get('streaming_api');
|
||||
|
@ -35,7 +43,7 @@ export function connectStream(
|
|||
}
|
||||
};
|
||||
|
||||
let subscription: WebSocketClient;
|
||||
let subscription: WebSocket;
|
||||
|
||||
// If the WebSocket fails to be created, don't crash the whole page,
|
||||
// just proceed without a subscription.
|
||||
|
@ -58,7 +66,7 @@ export function connectStream(
|
|||
},
|
||||
|
||||
received(data) {
|
||||
onReceive(data);
|
||||
onReceive(subscription, data);
|
||||
},
|
||||
|
||||
reconnected() {
|
||||
|
|
8
app/soapbox/types/nostr.ts
Normal file
8
app/soapbox/types/nostr.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import type { Event, EventTemplate } from 'nostr-tools';
|
||||
|
||||
interface Nostr {
|
||||
getPublicKey(): Promise<string>
|
||||
signEvent(event: EventTemplate): Promise<Event>
|
||||
}
|
||||
|
||||
export default Nostr;
|
7
app/soapbox/types/window.d.ts
vendored
Normal file
7
app/soapbox/types/window.d.ts
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
import type Nostr from './nostr';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
nostr?: Nostr
|
||||
}
|
||||
}
|
|
@ -493,6 +493,16 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
*/
|
||||
focalPoint: v.software === MASTODON && gte(v.compatVersion, '2.3.0'),
|
||||
|
||||
/**
|
||||
* Ability to follow hashtags.
|
||||
* @see POST /api/v1/tags/:name/follow
|
||||
* @see POST /api/v1/tags/:name/unfollow
|
||||
*/
|
||||
followHashtags: any([
|
||||
v.software === MASTODON && gte(v.compatVersion, '4.0.0'),
|
||||
v.software === PLEROMA && v.build === AKKOMA,
|
||||
]),
|
||||
|
||||
/**
|
||||
* Ability to lock accounts and manually approve followers.
|
||||
* @see PATCH /api/v1/accounts/update_credentials
|
||||
|
@ -502,6 +512,12 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
v.software === PLEROMA,
|
||||
]),
|
||||
|
||||
/**
|
||||
* Ability to list followed hashtags.
|
||||
* @see GET /api/v1/followed_tags
|
||||
*/
|
||||
followedHashtagsList: v.software === MASTODON && gte(v.compatVersion, '4.1.0'),
|
||||
|
||||
/**
|
||||
* Whether client settings can be retrieved from the API.
|
||||
* @see GET /api/pleroma/frontend_configurations
|
||||
|
@ -534,7 +550,7 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
* @see POST /api/v1/admin/groups/:group_id/unsuspend
|
||||
* @see DELETE /api/v1/admin/groups/:group_id
|
||||
*/
|
||||
groups: v.build === UNRELEASED,
|
||||
groups: v.software === TRUTHSOCIAL,
|
||||
|
||||
/**
|
||||
* Cap # of Group Admins to 5
|
||||
|
|
|
@ -1,22 +0,0 @@
|
|||
import { processHtml } from './tiny-post-html-processor';
|
||||
|
||||
export const addGreentext = (html: string): string => {
|
||||
// Copied from Pleroma FE
|
||||
// https://git.pleroma.social/pleroma/pleroma-fe/-/blob/19475ba356c3fd6c54ca0306d3ae392358c212d1/src/components/status_content/status_content.js#L132
|
||||
return processHtml(html, (string) => {
|
||||
try {
|
||||
if (string.includes('>') &&
|
||||
string
|
||||
.replace(/<[^>]+?>/gi, '') // remove all tags
|
||||
.replace(/@\w+/gi, '') // remove mentions (even failed ones)
|
||||
.trim()
|
||||
.startsWith('>')) {
|
||||
return `<span class='text-greentext'>${string}</span>`;
|
||||
} else {
|
||||
return string;
|
||||
}
|
||||
} catch (e) {
|
||||
return string;
|
||||
}
|
||||
});
|
||||
};
|
|
@ -1,95 +0,0 @@
|
|||
// Copied from Pleroma FE
|
||||
// https://git.pleroma.social/pleroma/pleroma-fe/-/blob/develop/src/services/tiny_post_html_processor/tiny_post_html_processor.service.js
|
||||
|
||||
type Processor = (html: string) => string;
|
||||
|
||||
/**
|
||||
* This is a tiny purpose-built HTML parser/processor. This basically detects any type of visual newline and
|
||||
* allows it to be processed, useful for greentexting, mostly.
|
||||
*
|
||||
* known issue: doesn't handle CDATA so nested CDATA might not work well.
|
||||
*/
|
||||
export const processHtml = (html: string, processor: Processor): string => {
|
||||
const handledTags = new Set(['p', 'br', 'div']);
|
||||
const openCloseTags = new Set(['p', 'div']);
|
||||
|
||||
let buffer = ''; // Current output buffer
|
||||
const level: string[] = []; // How deep we are in tags and which tags were there
|
||||
let textBuffer = ''; // Current line content
|
||||
let tagBuffer = null; // Current tag buffer, if null = we are not currently reading a tag
|
||||
|
||||
// Extracts tag name from tag, i.e. <span a="b"> => span
|
||||
const getTagName = (tag: string): string | null => {
|
||||
const result = /(?:<\/(\w+)>|<(\w+)\s?[^/]*?\/?>)/gi.exec(tag);
|
||||
return result && (result[1] || result[2]);
|
||||
};
|
||||
|
||||
const flush = (): void => { // Processes current line buffer, adds it to output buffer and clears line buffer
|
||||
if (textBuffer.trim().length > 0) {
|
||||
buffer += processor(textBuffer);
|
||||
} else {
|
||||
buffer += textBuffer;
|
||||
}
|
||||
textBuffer = '';
|
||||
};
|
||||
|
||||
const handleBr = (tag: string): void => { // handles single newlines/linebreaks/selfclosing
|
||||
flush();
|
||||
buffer += tag;
|
||||
};
|
||||
|
||||
const handleOpen = (tag: string): void => { // handles opening tags
|
||||
flush();
|
||||
buffer += tag;
|
||||
level.push(tag);
|
||||
};
|
||||
|
||||
const handleClose = (tag: string): void => { // handles closing tags
|
||||
flush();
|
||||
buffer += tag;
|
||||
if (level[level.length - 1] === tag) {
|
||||
level.pop();
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < html.length; i++) {
|
||||
const char = html[i];
|
||||
if (char === '<' && tagBuffer === null) {
|
||||
tagBuffer = char;
|
||||
} else if (char !== '>' && tagBuffer !== null) {
|
||||
tagBuffer += char;
|
||||
} else if (char === '>' && tagBuffer !== null) {
|
||||
tagBuffer += char;
|
||||
const tagFull = tagBuffer;
|
||||
tagBuffer = null;
|
||||
const tagName = getTagName(tagFull);
|
||||
if (tagName && handledTags.has(tagName)) {
|
||||
if (tagName === 'br') {
|
||||
handleBr(tagFull);
|
||||
} else if (openCloseTags.has(tagName)) {
|
||||
if (tagFull[1] === '/') {
|
||||
handleClose(tagFull);
|
||||
} else if (tagFull[tagFull.length - 2] === '/') {
|
||||
// self-closing
|
||||
handleBr(tagFull);
|
||||
} else {
|
||||
handleOpen(tagFull);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
textBuffer += tagFull;
|
||||
}
|
||||
} else if (char === '\n') {
|
||||
handleBr(char);
|
||||
} else {
|
||||
textBuffer += char;
|
||||
}
|
||||
}
|
||||
if (tagBuffer) {
|
||||
textBuffer += tagBuffer;
|
||||
}
|
||||
|
||||
flush();
|
||||
|
||||
return buffer;
|
||||
};
|
|
@ -1,5 +1,5 @@
|
|||
.thread {
|
||||
@apply bg-white dark:bg-primary-900 sm:rounded-xl;
|
||||
@apply bg-white dark:bg-primary-900;
|
||||
|
||||
&__status {
|
||||
@apply relative pb-4;
|
||||
|
|
|
@ -7,9 +7,6 @@
|
|||
}
|
||||
|
||||
.media-modal {
|
||||
// https://stackoverflow.com/a/8468131
|
||||
@apply w-full h-full absolute inset-0;
|
||||
|
||||
.audio-player.detailed,
|
||||
.extended-video-player {
|
||||
display: flex;
|
||||
|
@ -30,126 +27,6 @@
|
|||
@apply max-w-full max-h-[80%];
|
||||
}
|
||||
}
|
||||
|
||||
&__closer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&__navigation {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s linear;
|
||||
will-change: opacity;
|
||||
|
||||
* {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
&--hidden {
|
||||
opacity: 0;
|
||||
|
||||
* {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__nav {
|
||||
@apply absolute top-0 bottom-0 my-auto mx-0 box-border flex h-[20vmax] cursor-pointer items-center border-0 bg-black/50 text-2xl text-white;
|
||||
padding: 30px 15px;
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
@apply px-0.5;
|
||||
}
|
||||
|
||||
.svg-icon {
|
||||
@apply h-6 w-6;
|
||||
}
|
||||
|
||||
&--left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&--right {
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__pagination {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 20px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 20px;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
|
||||
&--shifted {
|
||||
bottom: 62px;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__page-dot {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&__button {
|
||||
background-color: #fff;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
border-radius: 6px;
|
||||
margin: 10px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-size: 0;
|
||||
|
||||
&--active {
|
||||
@apply bg-accent-500;
|
||||
}
|
||||
}
|
||||
|
||||
&__close {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
z-index: 100;
|
||||
color: #fff;
|
||||
|
||||
.svg-icon {
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error-modal {
|
||||
|
@ -198,24 +75,6 @@
|
|||
min-width: 33px;
|
||||
}
|
||||
}
|
||||
|
||||
&__nav {
|
||||
border: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 10px 25px;
|
||||
line-height: inherit;
|
||||
height: auto;
|
||||
margin: -10px;
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions-modal {
|
||||
|
|
|
@ -134,6 +134,7 @@
|
|||
"localforage": "^1.10.0",
|
||||
"lodash": "^4.7.11",
|
||||
"mini-css-extract-plugin": "^2.6.0",
|
||||
"nostr-tools": "^1.8.1",
|
||||
"path-browserify": "^1.0.1",
|
||||
"postcss": "^8.4.14",
|
||||
"postcss-loader": "^7.0.0",
|
||||
|
|
61
yarn.lock
61
yarn.lock
|
@ -2483,6 +2483,28 @@
|
|||
dependencies:
|
||||
eslint-scope "5.1.1"
|
||||
|
||||
"@noble/curves@~0.8.3":
|
||||
version "0.8.3"
|
||||
resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-0.8.3.tgz#ad6d48baf2599cf1d58dcb734c14d5225c8996e0"
|
||||
integrity sha512-OqaOf4RWDaCRuBKJLDURrgVxjLmneGsiCXGuzYB5y95YithZMA6w4uk34DHSm0rKMrrYiaeZj48/81EvaAScLQ==
|
||||
dependencies:
|
||||
"@noble/hashes" "1.3.0"
|
||||
|
||||
"@noble/hashes@1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.0.0.tgz#d5e38bfbdaba174805a4e649f13be9a9ed3351ae"
|
||||
integrity sha512-DZVbtY62kc3kkBtMHqwCOfXrT/hnoORy5BJ4+HU1IR59X0KWAOqsfzQPcUl/lQLlG7qXbe/fZ3r/emxtAl+sqg==
|
||||
|
||||
"@noble/hashes@1.3.0", "@noble/hashes@~1.3.0":
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.0.tgz#085fd70f6d7d9d109671090ccae1d3bec62554a1"
|
||||
integrity sha512-ilHEACi9DwqJB0pw7kv+Apvh50jiiSyR/cQ3y4W7lOR5mhvn/50FLUfsnfJz0BDZtl/RR16kXvptiv6q1msYZg==
|
||||
|
||||
"@noble/secp256k1@^1.7.1":
|
||||
version "1.7.1"
|
||||
resolved "https://registry.yarnpkg.com/@noble/secp256k1/-/secp256k1-1.7.1.tgz#b251c70f824ce3ca7f8dc3df08d58f005cc0507c"
|
||||
integrity sha512-hOUk6AyBFmqVrv7k5WAw/LpszxVbj9gGN4JRkIX52fdFAj1UA61KXmZDvqVEm+pOyec3+fIeZB02LYa/pWOArw==
|
||||
|
||||
"@nodelib/fs.scandir@2.1.5":
|
||||
version "2.1.5"
|
||||
resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"
|
||||
|
@ -2769,6 +2791,28 @@
|
|||
resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.2.1.tgz#812edd4104a15a493dda1ccac0b352270d7a188c"
|
||||
integrity sha512-XiY0IsyHR+DXYS5vBxpoBe/8veTeoRpMHP+vDosLZxL5bnpetzI0igkxkLZS235ldLzyfkxF+2divEwWHP3vMQ==
|
||||
|
||||
"@scure/base@^1.1.1", "@scure/base@~1.1.0":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.1.tgz#ebb651ee52ff84f420097055f4bf46cfba403938"
|
||||
integrity sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==
|
||||
|
||||
"@scure/bip32@^1.1.5":
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.2.0.tgz#35692d8f8cc3207200239fc119f9e038e5f465df"
|
||||
integrity sha512-O+vT/hBVk+ag2i6j2CDemwd1E1MtGt+7O1KzrPNsaNvSsiEK55MyPIxJIMI2PS8Ijj464B2VbQlpRoQXxw1uHg==
|
||||
dependencies:
|
||||
"@noble/curves" "~0.8.3"
|
||||
"@noble/hashes" "~1.3.0"
|
||||
"@scure/base" "~1.1.0"
|
||||
|
||||
"@scure/bip39@^1.1.1":
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-1.2.0.tgz#a207e2ef96de354de7d0002292ba1503538fc77b"
|
||||
integrity sha512-SX/uKq52cuxm4YFXWFaVByaSHJh2w3BnokVSeUJVCv6K7WulT9u2BuNRBhuFl8vAuYnzx9bEu9WgpcNYTrYieg==
|
||||
dependencies:
|
||||
"@noble/hashes" "~1.3.0"
|
||||
"@scure/base" "~1.1.0"
|
||||
|
||||
"@sentry/browser@7.37.2", "@sentry/browser@^7.37.2":
|
||||
version "7.37.2"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-7.37.2.tgz#355dd28ad12677d63e0b12c5209d12b3f98ac3a4"
|
||||
|
@ -12923,6 +12967,18 @@ normalize-url@^6.0.1:
|
|||
resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a"
|
||||
integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==
|
||||
|
||||
nostr-tools@^1.8.1:
|
||||
version "1.8.1"
|
||||
resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-1.8.1.tgz#4e54a354cc88ea0200634da3ee5a1c3466e1794c"
|
||||
integrity sha512-/2IUe5xINUYT5hYBoEz51dfRaodbRHnyF8n+ZbKWCoh0ZRX6AL88OoDNrWaWWo7tP5j5OyzSL9g/z4TP7bshEA==
|
||||
dependencies:
|
||||
"@noble/hashes" "1.0.0"
|
||||
"@noble/secp256k1" "^1.7.1"
|
||||
"@scure/base" "^1.1.1"
|
||||
"@scure/bip32" "^1.1.5"
|
||||
"@scure/bip39" "^1.1.1"
|
||||
prettier "^2.8.4"
|
||||
|
||||
npm-run-path@^2.0.0:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"
|
||||
|
@ -14101,6 +14157,11 @@ prelude-ls@~1.1.2:
|
|||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.3.0.tgz#b6a5bf1284026ae640f17f7ff5658a7567fc0d18"
|
||||
integrity sha512-kXtO4s0Lz/DW/IJ9QdWhAf7/NmPWQXkFr/r/WkR3vyI+0v8amTDxiaQSLzs8NBlytfLWX/7uQUMIW677yLKl4w==
|
||||
|
||||
prettier@^2.8.4:
|
||||
version "2.8.7"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.7.tgz#bb79fc8729308549d28fe3a98fce73d2c0656450"
|
||||
integrity sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw==
|
||||
|
||||
pretty-error@^2.1.1:
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-2.1.2.tgz#be89f82d81b1c86ec8fdfbc385045882727f93b6"
|
||||
|
|
Loading…
Reference in a new issue