Merge branch 'mastodon-groups' into 'develop'
Mastodon groups See merge request soapbox-pub/soapbox!1992
This commit is contained in:
commit
81f92a0231
|
@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
- Posts: bot badge on statuses from bot accounts.
|
- Posts: bot badge on statuses from bot accounts.
|
||||||
- Compatibility: improved browser support for older browsers.
|
- Compatibility: improved browser support for older browsers.
|
||||||
- Events: allow to repost events in event menu.
|
- Events: allow to repost events in event menu.
|
||||||
|
- Groups: Initial support for groups.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Chats: improved display of media attachments.
|
- Chats: improved display of media attachments.
|
||||||
|
|
|
@ -46,6 +46,7 @@ const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
|
||||||
const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
|
const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
|
||||||
const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
|
const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
|
||||||
const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
|
const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
|
||||||
|
const COMPOSE_GROUP_POST = 'COMPOSE_GROUP_POST';
|
||||||
|
|
||||||
const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
|
const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
|
||||||
const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
|
const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
|
||||||
|
@ -288,6 +289,7 @@ const submitCompose = (composeId: string, routerHistory?: History, force = false
|
||||||
poll: compose.poll,
|
poll: compose.poll,
|
||||||
scheduled_at: compose.schedule,
|
scheduled_at: compose.schedule,
|
||||||
to,
|
to,
|
||||||
|
group_id: compose.privacy === 'group' ? compose.group_id : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
dispatch(createStatus(params, idempotencyKey, statusId)).then(function(data) {
|
dispatch(createStatus(params, idempotencyKey, statusId)).then(function(data) {
|
||||||
|
@ -470,6 +472,15 @@ const undoUploadCompose = (composeId: string, media_id: string) => ({
|
||||||
media_id: media_id,
|
media_id: media_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const groupCompose = (composeId: string, groupId: string) =>
|
||||||
|
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
|
dispatch({
|
||||||
|
type: COMPOSE_GROUP_POST,
|
||||||
|
id: composeId,
|
||||||
|
group_id: groupId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const clearComposeSuggestions = (composeId: string) => {
|
const clearComposeSuggestions = (composeId: string) => {
|
||||||
if (cancelFetchComposeSuggestionsAccounts) {
|
if (cancelFetchComposeSuggestionsAccounts) {
|
||||||
cancelFetchComposeSuggestionsAccounts();
|
cancelFetchComposeSuggestionsAccounts();
|
||||||
|
@ -722,7 +733,7 @@ const eventDiscussionCompose = (composeId: string, status: Status) =>
|
||||||
const instance = state.instance;
|
const instance = state.instance;
|
||||||
const { explicitAddressing } = getFeatures(instance);
|
const { explicitAddressing } = getFeatures(instance);
|
||||||
|
|
||||||
dispatch({
|
return dispatch({
|
||||||
type: COMPOSE_EVENT_REPLY,
|
type: COMPOSE_EVENT_REPLY,
|
||||||
id: composeId,
|
id: composeId,
|
||||||
status: status,
|
status: status,
|
||||||
|
@ -749,6 +760,7 @@ export {
|
||||||
COMPOSE_UPLOAD_FAIL,
|
COMPOSE_UPLOAD_FAIL,
|
||||||
COMPOSE_UPLOAD_PROGRESS,
|
COMPOSE_UPLOAD_PROGRESS,
|
||||||
COMPOSE_UPLOAD_UNDO,
|
COMPOSE_UPLOAD_UNDO,
|
||||||
|
COMPOSE_GROUP_POST,
|
||||||
COMPOSE_SUGGESTIONS_CLEAR,
|
COMPOSE_SUGGESTIONS_CLEAR,
|
||||||
COMPOSE_SUGGESTIONS_READY,
|
COMPOSE_SUGGESTIONS_READY,
|
||||||
COMPOSE_SUGGESTION_SELECT,
|
COMPOSE_SUGGESTION_SELECT,
|
||||||
|
@ -801,6 +813,7 @@ export {
|
||||||
uploadComposeSuccess,
|
uploadComposeSuccess,
|
||||||
uploadComposeFail,
|
uploadComposeFail,
|
||||||
undoUploadCompose,
|
undoUploadCompose,
|
||||||
|
groupCompose,
|
||||||
clearComposeSuggestions,
|
clearComposeSuggestions,
|
||||||
fetchComposeSuggestions,
|
fetchComposeSuggestions,
|
||||||
readyComposeSuggestionsEmojis,
|
readyComposeSuggestionsEmojis,
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -5,42 +5,44 @@ import type { APIEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT';
|
const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT';
|
||||||
const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT';
|
const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT';
|
||||||
|
const GROUP_IMPORT = 'GROUP_IMPORT';
|
||||||
|
const GROUPS_IMPORT = 'GROUPS_IMPORT';
|
||||||
const STATUS_IMPORT = 'STATUS_IMPORT';
|
const STATUS_IMPORT = 'STATUS_IMPORT';
|
||||||
const STATUSES_IMPORT = 'STATUSES_IMPORT';
|
const STATUSES_IMPORT = 'STATUSES_IMPORT';
|
||||||
const POLLS_IMPORT = 'POLLS_IMPORT';
|
const POLLS_IMPORT = 'POLLS_IMPORT';
|
||||||
const ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP = 'ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP';
|
const ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP = 'ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP';
|
||||||
|
|
||||||
export function importAccount(account: APIEntity) {
|
const importAccount = (account: APIEntity) =>
|
||||||
return { type: ACCOUNT_IMPORT, account };
|
({ type: ACCOUNT_IMPORT, account });
|
||||||
}
|
|
||||||
|
|
||||||
export function importAccounts(accounts: APIEntity[]) {
|
const importAccounts = (accounts: APIEntity[]) =>
|
||||||
return { type: ACCOUNTS_IMPORT, accounts };
|
({ type: ACCOUNTS_IMPORT, accounts });
|
||||||
}
|
|
||||||
|
|
||||||
export function importStatus(status: APIEntity, idempotencyKey?: string) {
|
const importGroup = (group: APIEntity) =>
|
||||||
return (dispatch: AppDispatch, getState: () => RootState) => {
|
({ type: GROUP_IMPORT, group });
|
||||||
|
|
||||||
|
const importGroups = (groups: APIEntity[]) =>
|
||||||
|
({ type: GROUPS_IMPORT, groups });
|
||||||
|
|
||||||
|
const importStatus = (status: APIEntity, idempotencyKey?: string) =>
|
||||||
|
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
const expandSpoilers = getSettings(getState()).get('expandSpoilers');
|
const expandSpoilers = getSettings(getState()).get('expandSpoilers');
|
||||||
return dispatch({ type: STATUS_IMPORT, status, idempotencyKey, expandSpoilers });
|
return dispatch({ type: STATUS_IMPORT, status, idempotencyKey, expandSpoilers });
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export function importStatuses(statuses: APIEntity[]) {
|
const importStatuses = (statuses: APIEntity[]) =>
|
||||||
return (dispatch: AppDispatch, getState: () => RootState) => {
|
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
const expandSpoilers = getSettings(getState()).get('expandSpoilers');
|
const expandSpoilers = getSettings(getState()).get('expandSpoilers');
|
||||||
return dispatch({ type: STATUSES_IMPORT, statuses, expandSpoilers });
|
return dispatch({ type: STATUSES_IMPORT, statuses, expandSpoilers });
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export function importPolls(polls: APIEntity[]) {
|
const importPolls = (polls: APIEntity[]) =>
|
||||||
return { type: POLLS_IMPORT, polls };
|
({ type: POLLS_IMPORT, polls });
|
||||||
}
|
|
||||||
|
|
||||||
export function importFetchedAccount(account: APIEntity) {
|
const importFetchedAccount = (account: APIEntity) =>
|
||||||
return importFetchedAccounts([account]);
|
importFetchedAccounts([account]);
|
||||||
}
|
|
||||||
|
|
||||||
export function importFetchedAccounts(accounts: APIEntity[], args = { should_refetch: false }) {
|
const importFetchedAccounts = (accounts: APIEntity[], args = { should_refetch: false }) => {
|
||||||
const { should_refetch } = args;
|
const { should_refetch } = args;
|
||||||
const normalAccounts: APIEntity[] = [];
|
const normalAccounts: APIEntity[] = [];
|
||||||
|
|
||||||
|
@ -61,10 +63,27 @@ export function importFetchedAccounts(accounts: APIEntity[], args = { should_ref
|
||||||
accounts.forEach(processAccount);
|
accounts.forEach(processAccount);
|
||||||
|
|
||||||
return importAccounts(normalAccounts);
|
return importAccounts(normalAccounts);
|
||||||
}
|
};
|
||||||
|
|
||||||
export function importFetchedStatus(status: APIEntity, idempotencyKey?: string) {
|
const importFetchedGroup = (group: APIEntity) =>
|
||||||
return (dispatch: AppDispatch) => {
|
importFetchedGroups([group]);
|
||||||
|
|
||||||
|
const importFetchedGroups = (groups: APIEntity[]) => {
|
||||||
|
const normalGroups: APIEntity[] = [];
|
||||||
|
|
||||||
|
const processGroup = (group: APIEntity) => {
|
||||||
|
if (!group.id) return;
|
||||||
|
|
||||||
|
normalGroups.push(group);
|
||||||
|
};
|
||||||
|
|
||||||
|
groups.forEach(processGroup);
|
||||||
|
|
||||||
|
return importGroups(normalGroups);
|
||||||
|
};
|
||||||
|
|
||||||
|
const importFetchedStatus = (status: APIEntity, idempotencyKey?: string) =>
|
||||||
|
(dispatch: AppDispatch) => {
|
||||||
// Skip broken statuses
|
// Skip broken statuses
|
||||||
if (isBroken(status)) return;
|
if (isBroken(status)) return;
|
||||||
|
|
||||||
|
@ -96,10 +115,13 @@ export function importFetchedStatus(status: APIEntity, idempotencyKey?: string)
|
||||||
dispatch(importFetchedPoll(status.poll));
|
dispatch(importFetchedPoll(status.poll));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status.group?.id) {
|
||||||
|
dispatch(importFetchedGroup(status.group));
|
||||||
|
}
|
||||||
|
|
||||||
dispatch(importFetchedAccount(status.account));
|
dispatch(importFetchedAccount(status.account));
|
||||||
dispatch(importStatus(status, idempotencyKey));
|
dispatch(importStatus(status, idempotencyKey));
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// Sometimes Pleroma can return an empty account,
|
// Sometimes Pleroma can return an empty account,
|
||||||
// or a repost can appear of a deleted account. Skip these statuses.
|
// or a repost can appear of a deleted account. Skip these statuses.
|
||||||
|
@ -117,8 +139,8 @@ const isBroken = (status: APIEntity) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export function importFetchedStatuses(statuses: APIEntity[]) {
|
const importFetchedStatuses = (statuses: APIEntity[]) =>
|
||||||
return (dispatch: AppDispatch, getState: () => RootState) => {
|
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
const accounts: APIEntity[] = [];
|
const accounts: APIEntity[] = [];
|
||||||
const normalStatuses: APIEntity[] = [];
|
const normalStatuses: APIEntity[] = [];
|
||||||
const polls: APIEntity[] = [];
|
const polls: APIEntity[] = [];
|
||||||
|
@ -146,6 +168,10 @@ export function importFetchedStatuses(statuses: APIEntity[]) {
|
||||||
if (status.poll?.id) {
|
if (status.poll?.id) {
|
||||||
polls.push(status.poll);
|
polls.push(status.poll);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status.group?.id) {
|
||||||
|
dispatch(importFetchedGroup(status.group));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
statuses.forEach(processStatus);
|
statuses.forEach(processStatus);
|
||||||
|
@ -154,23 +180,37 @@ export function importFetchedStatuses(statuses: APIEntity[]) {
|
||||||
dispatch(importFetchedAccounts(accounts));
|
dispatch(importFetchedAccounts(accounts));
|
||||||
dispatch(importStatuses(normalStatuses));
|
dispatch(importStatuses(normalStatuses));
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export function importFetchedPoll(poll: APIEntity) {
|
const importFetchedPoll = (poll: APIEntity) =>
|
||||||
return (dispatch: AppDispatch) => {
|
(dispatch: AppDispatch) => {
|
||||||
dispatch(importPolls([poll]));
|
dispatch(importPolls([poll]));
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export function importErrorWhileFetchingAccountByUsername(username: string) {
|
const importErrorWhileFetchingAccountByUsername = (username: string) =>
|
||||||
return { type: ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP, username };
|
({ type: ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP, username });
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
ACCOUNT_IMPORT,
|
ACCOUNT_IMPORT,
|
||||||
ACCOUNTS_IMPORT,
|
ACCOUNTS_IMPORT,
|
||||||
|
GROUP_IMPORT,
|
||||||
|
GROUPS_IMPORT,
|
||||||
STATUS_IMPORT,
|
STATUS_IMPORT,
|
||||||
STATUSES_IMPORT,
|
STATUSES_IMPORT,
|
||||||
POLLS_IMPORT,
|
POLLS_IMPORT,
|
||||||
ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP,
|
ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP,
|
||||||
|
importAccount,
|
||||||
|
importAccounts,
|
||||||
|
importGroup,
|
||||||
|
importGroups,
|
||||||
|
importStatus,
|
||||||
|
importStatuses,
|
||||||
|
importPolls,
|
||||||
|
importFetchedAccount,
|
||||||
|
importFetchedAccounts,
|
||||||
|
importFetchedGroup,
|
||||||
|
importFetchedGroups,
|
||||||
|
importFetchedStatus,
|
||||||
|
importFetchedStatuses,
|
||||||
|
importFetchedPoll,
|
||||||
|
importErrorWhileFetchingAccountByUsername,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
|
||||||
import { fetchRelationships } from './accounts';
|
import { fetchRelationships } from './accounts';
|
||||||
import { importFetchedAccounts, importFetchedStatuses } from './importer';
|
import { importFetchedAccounts, importFetchedGroups, importFetchedStatuses } from './importer';
|
||||||
|
|
||||||
import type { AxiosError } from 'axios';
|
import type { AxiosError } from 'axios';
|
||||||
import type { SearchFilter } from 'soapbox/reducers/search';
|
import type { SearchFilter } from 'soapbox/reducers/search';
|
||||||
|
@ -83,6 +83,10 @@ const submitSearch = (filter?: SearchFilter) =>
|
||||||
dispatch(importFetchedStatuses(response.data.statuses));
|
dispatch(importFetchedStatuses(response.data.statuses));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (response.data.groups) {
|
||||||
|
dispatch(importFetchedGroups(response.data.groups));
|
||||||
|
}
|
||||||
|
|
||||||
dispatch(fetchSearchSuccess(response.data, value, type));
|
dispatch(fetchSearchSuccess(response.data, value, type));
|
||||||
dispatch(fetchRelationships(response.data.accounts.map((item: APIEntity) => item.id)));
|
dispatch(fetchRelationships(response.data.accounts.map((item: APIEntity) => item.id)));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
|
@ -139,6 +143,10 @@ const expandSearch = (type: SearchFilter) => (dispatch: AppDispatch, getState: (
|
||||||
dispatch(importFetchedStatuses(data.statuses));
|
dispatch(importFetchedStatuses(data.statuses));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.groups) {
|
||||||
|
dispatch(importFetchedGroups(data.groups));
|
||||||
|
}
|
||||||
|
|
||||||
dispatch(expandSearchSuccess(data, value, type));
|
dispatch(expandSearchSuccess(data, value, type));
|
||||||
dispatch(fetchRelationships(data.accounts.map((item: APIEntity) => item.id)));
|
dispatch(fetchRelationships(data.accounts.map((item: APIEntity) => item.id)));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
|
|
|
@ -156,6 +156,8 @@ const defaultSettings = ImmutableMap({
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
groups: ImmutableMap({}),
|
||||||
|
|
||||||
trends: ImmutableMap({
|
trends: ImmutableMap({
|
||||||
show: true,
|
show: true,
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -219,6 +219,9 @@ const expandListTimeline = (id: string, { maxId }: Record<string, any> = {}, don
|
||||||
const expandGroupTimeline = (id: string, { maxId }: Record<string, any> = {}, done = noOp) =>
|
const expandGroupTimeline = (id: string, { maxId }: Record<string, any> = {}, done = noOp) =>
|
||||||
expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done);
|
expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done);
|
||||||
|
|
||||||
|
const expandGroupMediaTimeline = (id: string | number, { maxId }: Record<string, any> = {}) =>
|
||||||
|
expandTimeline(`group:${id}:media`, `/api/v1/timelines/group/${id}`, { max_id: maxId, only_media: true, limit: 40, with_muted: true });
|
||||||
|
|
||||||
const expandHashtagTimeline = (hashtag: string, { maxId, tags }: Record<string, any> = {}, done = noOp) => {
|
const expandHashtagTimeline = (hashtag: string, { maxId, tags }: Record<string, any> = {}, done = noOp) => {
|
||||||
return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
|
return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
|
||||||
max_id: maxId,
|
max_id: maxId,
|
||||||
|
@ -309,6 +312,7 @@ export {
|
||||||
expandAccountMediaTimeline,
|
expandAccountMediaTimeline,
|
||||||
expandListTimeline,
|
expandListTimeline,
|
||||||
expandGroupTimeline,
|
expandGroupTimeline,
|
||||||
|
expandGroupMediaTimeline,
|
||||||
expandHashtagTimeline,
|
expandHashtagTimeline,
|
||||||
expandTimelineRequest,
|
expandTimelineRequest,
|
||||||
expandTimelineSuccess,
|
expandTimelineSuccess,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||||
import { Link, useHistory } from 'react-router-dom';
|
import { Link, useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
|
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
|
||||||
|
@ -13,6 +13,7 @@ import Badge from './badge';
|
||||||
import RelativeTimestamp from './relative-timestamp';
|
import RelativeTimestamp from './relative-timestamp';
|
||||||
import { Avatar, Emoji, HStack, Icon, IconButton, Stack, Text } from './ui';
|
import { Avatar, Emoji, HStack, Icon, IconButton, Stack, Text } from './ui';
|
||||||
|
|
||||||
|
import type { StatusApprovalStatus } from 'soapbox/normalizers/status';
|
||||||
import type { Account as AccountEntity } from 'soapbox/types/entities';
|
import type { Account as AccountEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
interface IInstanceFavicon {
|
interface IInstanceFavicon {
|
||||||
|
@ -87,6 +88,7 @@ export interface IAccount {
|
||||||
withLinkToProfile?: boolean,
|
withLinkToProfile?: boolean,
|
||||||
withRelationship?: boolean,
|
withRelationship?: boolean,
|
||||||
showEdit?: boolean,
|
showEdit?: boolean,
|
||||||
|
approvalStatus?: StatusApprovalStatus,
|
||||||
emoji?: string,
|
emoji?: string,
|
||||||
note?: string,
|
note?: string,
|
||||||
}
|
}
|
||||||
|
@ -111,6 +113,7 @@ const Account = ({
|
||||||
withLinkToProfile = true,
|
withLinkToProfile = true,
|
||||||
withRelationship = true,
|
withRelationship = true,
|
||||||
showEdit = false,
|
showEdit = false,
|
||||||
|
approvalStatus,
|
||||||
emoji,
|
emoji,
|
||||||
note,
|
note,
|
||||||
}: IAccount) => {
|
}: IAccount) => {
|
||||||
|
@ -259,6 +262,18 @@ const Account = ({
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{approvalStatus && ['pending', 'rejected'].includes(approvalStatus) && (
|
||||||
|
<>
|
||||||
|
<Text tag='span' theme='muted' size='sm'>·</Text>
|
||||||
|
|
||||||
|
<Text tag='span' theme='muted' size='sm'>
|
||||||
|
{approvalStatus === 'pending'
|
||||||
|
? <FormattedMessage id='status.approval.pending' defaultMessage='Pending approval' />
|
||||||
|
: <FormattedMessage id='status.approval.rejected' defaultMessage='Rejected' />}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{showEdit ? (
|
{showEdit ? (
|
||||||
<>
|
<>
|
||||||
<Text tag='span' theme='muted' size='sm'>·</Text>
|
<Text tag='span' theme='muted' size='sm'>·</Text>
|
||||||
|
|
|
@ -13,7 +13,7 @@ import VerificationBadge from './verification-badge';
|
||||||
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities';
|
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
bannerHeader: { id: 'event.banner', defaultMessage: 'Event banner' },
|
eventBanner: { id: 'event.banner', defaultMessage: 'Event banner' },
|
||||||
leaveConfirm: { id: 'confirmations.leave_event.confirm', defaultMessage: 'Leave event' },
|
leaveConfirm: { id: 'confirmations.leave_event.confirm', defaultMessage: 'Leave event' },
|
||||||
leaveMessage: { id: 'confirmations.leave_event.message', defaultMessage: 'If you want to rejoin the event, the request will be manually reviewed again. Are you sure you want to proceed?' },
|
leaveMessage: { id: 'confirmations.leave_event.message', defaultMessage: 'If you want to rejoin the event, the request will be manually reviewed again. Are you sure you want to proceed?' },
|
||||||
});
|
});
|
||||||
|
@ -56,7 +56,7 @@ const EventPreview: React.FC<IEventPreview> = ({ status, className, hideAction,
|
||||||
{floatingAction && action}
|
{floatingAction && action}
|
||||||
</div>
|
</div>
|
||||||
<div className='bg-primary-200 dark:bg-gray-600 h-40'>
|
<div className='bg-primary-200 dark:bg-gray-600 h-40'>
|
||||||
{banner && <img className='h-full w-full object-cover' src={banner.url} alt={intl.formatMessage(messages.bannerHeader)} />}
|
{banner && <img className='h-full w-full object-cover' src={banner.url} alt={intl.formatMessage(messages.eventBanner)} />}
|
||||||
</div>
|
</div>
|
||||||
<Stack className='p-2.5' space={2}>
|
<Stack className='p-2.5' space={2}>
|
||||||
<HStack space={2} alignItems='center' justifyContent='between'>
|
<HStack space={2} alignItems='center' justifyContent='between'>
|
||||||
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { Avatar, HStack, Icon, Stack, Text } from './ui';
|
||||||
|
|
||||||
|
import type { Group as GroupEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
groupHeader: { id: 'group.header.alt', defaultMessage: 'Group header' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IGroupCard {
|
||||||
|
group: GroupEntity
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupCard: React.FC<IGroupCard> = ({ group }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='overflow-hidden'>
|
||||||
|
<Stack className='bg-white dark:bg-primary-900 border border-solid border-gray-300 dark:border-primary-800 rounded-lg sm:rounded-xl'>
|
||||||
|
<div className='bg-primary-100 dark:bg-gray-800 h-[120px] relative -m-[1px] mb-0 rounded-t-lg sm:rounded-t-xl'>
|
||||||
|
{group.header && <img className='h-full w-full object-cover rounded-t-lg sm:rounded-t-xl' src={group.header} alt={intl.formatMessage(messages.groupHeader)} />}
|
||||||
|
<div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
|
||||||
|
<Avatar className='ring-2 ring-white dark:ring-primary-900' src={group.avatar} size={64} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Stack className='p-3 pt-9' alignItems='center' space={3}>
|
||||||
|
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||||
|
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
|
||||||
|
{group.relationship?.role === 'admin' ? (
|
||||||
|
<HStack space={1} alignItems='center'>
|
||||||
|
<Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} />
|
||||||
|
<span><FormattedMessage id='group.role.admin' defaultMessage='Admin' /></span>
|
||||||
|
</HStack>
|
||||||
|
) : group.relationship?.role === 'moderator' && (
|
||||||
|
<HStack space={1} alignItems='center'>
|
||||||
|
<Icon className='h-4 w-4' src={require('@tabler/icons/gavel.svg')} />
|
||||||
|
<span><FormattedMessage id='group.role.moderator' defaultMessage='Moderator' /></span>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
{group.locked ? (
|
||||||
|
<HStack space={1} alignItems='center'>
|
||||||
|
<Icon className='h-4 w-4' src={require('@tabler/icons/lock.svg')} />
|
||||||
|
<span><FormattedMessage id='group.privacy.locked' defaultMessage='Private' /></span>
|
||||||
|
</HStack>
|
||||||
|
) : (
|
||||||
|
<HStack space={1} alignItems='center'>
|
||||||
|
<Icon className='h-4 w-4' src={require('@tabler/icons/world.svg')} />
|
||||||
|
<span><FormattedMessage id='group.privacy.public' defaultMessage='Public' /></span>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupCard;
|
|
@ -33,6 +33,7 @@ const messages = defineMessages({
|
||||||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
||||||
bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
||||||
lists: { id: 'column.lists', defaultMessage: 'Lists' },
|
lists: { id: 'column.lists', defaultMessage: 'Lists' },
|
||||||
|
groups: { id: 'column.groups', defaultMessage: 'Groups' },
|
||||||
events: { id: 'column.events', defaultMessage: 'Events' },
|
events: { id: 'column.events', defaultMessage: 'Events' },
|
||||||
invites: { id: 'navigation_bar.invites', defaultMessage: 'Invites' },
|
invites: { id: 'navigation_bar.invites', defaultMessage: 'Invites' },
|
||||||
developers: { id: 'navigation.developers', defaultMessage: 'Developers' },
|
developers: { id: 'navigation.developers', defaultMessage: 'Developers' },
|
||||||
|
@ -207,6 +208,15 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{features.groups && (
|
||||||
|
<SidebarLink
|
||||||
|
to='/groups'
|
||||||
|
icon={require('@tabler/icons/circles.svg')}
|
||||||
|
text={intl.formatMessage(messages.groups)}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{features.lists && (
|
{features.lists && (
|
||||||
<SidebarLink
|
<SidebarLink
|
||||||
to='/lists'
|
to='/lists'
|
||||||
|
|
|
@ -135,6 +135,14 @@ const SidebarNavigation = () => {
|
||||||
|
|
||||||
{renderMessagesLink()}
|
{renderMessagesLink()}
|
||||||
|
|
||||||
|
{features.groups && (
|
||||||
|
<SidebarNavigationLink
|
||||||
|
to='/groups'
|
||||||
|
icon={require('@tabler/icons/circles.svg')}
|
||||||
|
text={<FormattedMessage id='tabs_bar.groups' defaultMessage='Groups' />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<SidebarNavigationLink
|
<SidebarNavigationLink
|
||||||
to={`/@${account.acct}`}
|
to={`/@${account.acct}`}
|
||||||
icon={require('@tabler/icons/user.svg')}
|
icon={require('@tabler/icons/user.svg')}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { blockAccount } from 'soapbox/actions/accounts';
|
||||||
import { launchChat } from 'soapbox/actions/chats';
|
import { launchChat } from 'soapbox/actions/chats';
|
||||||
import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose';
|
import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose';
|
||||||
import { editEvent } from 'soapbox/actions/events';
|
import { editEvent } from 'soapbox/actions/events';
|
||||||
|
import { groupBlock, groupDeleteStatus, groupKick } from 'soapbox/actions/groups';
|
||||||
import { toggleBookmark, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions';
|
import { toggleBookmark, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions';
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
|
import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
|
||||||
|
@ -24,7 +25,7 @@ import copy from 'soapbox/utils/copy';
|
||||||
import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji-reacts';
|
import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji-reacts';
|
||||||
|
|
||||||
import type { Menu } from 'soapbox/components/dropdown-menu';
|
import type { Menu } from 'soapbox/components/dropdown-menu';
|
||||||
import type { Account, Status } from 'soapbox/types/entities';
|
import type { Account, Group, Status } from 'soapbox/types/entities';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||||
|
@ -81,6 +82,18 @@ const messages = defineMessages({
|
||||||
redraftHeading: { id: 'confirmations.redraft.heading', defaultMessage: 'Delete & redraft' },
|
redraftHeading: { id: 'confirmations.redraft.heading', defaultMessage: 'Delete & redraft' },
|
||||||
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
|
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
|
||||||
blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' },
|
blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' },
|
||||||
|
replies_disabled_group: { id: 'status.disabled_replies.group_membership', defaultMessage: 'Only group members can reply' },
|
||||||
|
groupModDelete: { id: 'status.group_mod_delete', defaultMessage: 'Delete post from group' },
|
||||||
|
groupModKick: { id: 'status.group_mod_kick', defaultMessage: 'Kick @{name} from group' },
|
||||||
|
groupModBlock: { id: 'status.group_mod_block', defaultMessage: 'Block @{name} from group' },
|
||||||
|
deleteFromGroupHeading: { id: 'confirmations.delete_from_group.heading', defaultMessage: 'Delete from group' },
|
||||||
|
deleteFromGroupMessage: { id: 'confirmations.delete_from_group.message', defaultMessage: 'Are you sure you want to delete @{name}\'s post?' },
|
||||||
|
kickFromGroupHeading: { id: 'confirmations.kick_from_group.heading', defaultMessage: 'Kick group member' },
|
||||||
|
kickFromGroupMessage: { id: 'confirmations.kick_from_group.message', defaultMessage: 'Are you sure you want to kick @{name} from this group?' },
|
||||||
|
kickFromGroupConfirm: { id: 'confirmations.kick_from_group.confirm', defaultMessage: 'Kick' },
|
||||||
|
blockFromGroupHeading: { id: 'confirmations.block_from_group.heading', defaultMessage: 'Block group member' },
|
||||||
|
blockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to block @{name} from interacting with this group?' },
|
||||||
|
blockFromGroupConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Block' },
|
||||||
});
|
});
|
||||||
|
|
||||||
interface IStatusActionBar {
|
interface IStatusActionBar {
|
||||||
|
@ -103,6 +116,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const me = useAppSelector(state => state.me);
|
const me = useAppSelector(state => state.me);
|
||||||
|
const groupRelationship = useAppSelector(state => status.group ? state.group_relationships.get((status.group as Group).id) : null);
|
||||||
const features = useFeatures();
|
const features = useFeatures();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const soapboxConfig = useSoapboxConfig();
|
const soapboxConfig = useSoapboxConfig();
|
||||||
|
@ -285,6 +299,39 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||||
dispatch(toggleStatusSensitivityModal(intl, status.id, status.sensitive));
|
dispatch(toggleStatusSensitivityModal(intl, status.id, status.sensitive));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteFromGroup: React.EventHandler<React.MouseEvent> = () => {
|
||||||
|
const account = status.account as Account;
|
||||||
|
|
||||||
|
dispatch(openModal('CONFIRM', {
|
||||||
|
heading: intl.formatMessage(messages.deleteHeading),
|
||||||
|
message: intl.formatMessage(messages.deleteFromGroupMessage, { name: account.username }),
|
||||||
|
confirm: intl.formatMessage(messages.deleteConfirm),
|
||||||
|
onConfirm: () => dispatch(groupDeleteStatus((status.group as Group).id, status.id)),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKickFromGroup: React.EventHandler<React.MouseEvent> = () => {
|
||||||
|
const account = status.account as Account;
|
||||||
|
|
||||||
|
dispatch(openModal('CONFIRM', {
|
||||||
|
heading: intl.formatMessage(messages.kickFromGroupHeading),
|
||||||
|
message: intl.formatMessage(messages.kickFromGroupMessage, { name: account.username }),
|
||||||
|
confirm: intl.formatMessage(messages.kickFromGroupConfirm),
|
||||||
|
onConfirm: () => dispatch(groupKick((status.group as Group).id, account.id)),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlockFromGroup: React.EventHandler<React.MouseEvent> = () => {
|
||||||
|
const account = status.account as Account;
|
||||||
|
|
||||||
|
dispatch(openModal('CONFIRM', {
|
||||||
|
heading: intl.formatMessage(messages.blockFromGroupHeading),
|
||||||
|
message: intl.formatMessage(messages.blockFromGroupMessage, { name: account.username }),
|
||||||
|
confirm: intl.formatMessage(messages.blockFromGroupConfirm),
|
||||||
|
onConfirm: () => dispatch(groupBlock((status.group as Group).id, account.id)),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const _makeMenu = (publicStatus: boolean) => {
|
const _makeMenu = (publicStatus: boolean) => {
|
||||||
const mutingConversation = status.muted;
|
const mutingConversation = status.muted;
|
||||||
const ownAccount = status.getIn(['account', 'id']) === me;
|
const ownAccount = status.getIn(['account', 'id']) === me;
|
||||||
|
@ -425,6 +472,26 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status.group && groupRelationship?.role && ['admin', 'moderator'].includes(groupRelationship.role)) {
|
||||||
|
menu.push(null);
|
||||||
|
menu.push({
|
||||||
|
text: intl.formatMessage(messages.groupModDelete),
|
||||||
|
action: handleDeleteFromGroup,
|
||||||
|
icon: require('@tabler/icons/trash.svg'),
|
||||||
|
});
|
||||||
|
// TODO: figure out when an account is not in the group anymore
|
||||||
|
menu.push({
|
||||||
|
text: intl.formatMessage(messages.groupModKick, { name: account.get('username') }),
|
||||||
|
action: handleKickFromGroup,
|
||||||
|
icon: require('@tabler/icons/user-minus.svg'),
|
||||||
|
});
|
||||||
|
menu.push({
|
||||||
|
text: intl.formatMessage(messages.groupModBlock, { name: account.get('username') }),
|
||||||
|
action: handleBlockFromGroup,
|
||||||
|
icon: require('@tabler/icons/ban.svg'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (isStaff) {
|
if (isStaff) {
|
||||||
menu.push(null);
|
menu.push(null);
|
||||||
|
|
||||||
|
@ -491,6 +558,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||||
const menu = _makeMenu(publicStatus);
|
const menu = _makeMenu(publicStatus);
|
||||||
let reblogIcon = require('@tabler/icons/repeat.svg');
|
let reblogIcon = require('@tabler/icons/repeat.svg');
|
||||||
let replyTitle;
|
let replyTitle;
|
||||||
|
let replyDisabled = false;
|
||||||
|
|
||||||
if (status.visibility === 'direct') {
|
if (status.visibility === 'direct') {
|
||||||
reblogIcon = require('@tabler/icons/mail.svg');
|
reblogIcon = require('@tabler/icons/mail.svg');
|
||||||
|
@ -498,6 +566,11 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||||
reblogIcon = require('@tabler/icons/lock.svg');
|
reblogIcon = require('@tabler/icons/lock.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((status.group as Group)?.membership_required && !groupRelationship?.member) {
|
||||||
|
replyDisabled = true;
|
||||||
|
replyTitle = intl.formatMessage(messages.replies_disabled_group);
|
||||||
|
}
|
||||||
|
|
||||||
const reblogMenu = [{
|
const reblogMenu = [{
|
||||||
text: intl.formatMessage(status.reblogged ? messages.cancel_reblog_private : messages.reblog),
|
text: intl.formatMessage(status.reblogged ? messages.cancel_reblog_private : messages.reblog),
|
||||||
action: handleReblogClick,
|
action: handleReblogClick,
|
||||||
|
@ -543,6 +616,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||||
onClick={handleReplyClick}
|
onClick={handleReplyClick}
|
||||||
count={replyCount}
|
count={replyCount}
|
||||||
text={withLabels ? intl.formatMessage(messages.reply) : undefined}
|
text={withLabels ? intl.formatMessage(messages.reply) : undefined}
|
||||||
|
disabled={replyDisabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(features.quotePosts && me) ? (
|
{(features.quotePosts && me) ? (
|
||||||
|
|
|
@ -85,6 +85,7 @@ const StatusActionButton = React.forwardRef<HTMLButtonElement, IStatusActionButt
|
||||||
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:ring-offset-0',
|
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:ring-offset-0',
|
||||||
{
|
{
|
||||||
'text-black dark:text-white': active && emoji,
|
'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,
|
'text-accent-300 hover:text-accent-300 dark:hover:text-accent-300': active && !emoji && color === COLORS.accent,
|
||||||
'text-success-600 hover:text-success-600 dark:hover:text-success-600': active && !emoji && color === COLORS.success,
|
'text-success-600 hover:text-success-600 dark:hover:text-success-600': active && !emoji && color === COLORS.success,
|
||||||
'space-x-1': !text,
|
'space-x-1': !text,
|
||||||
|
|
|
@ -46,6 +46,8 @@ interface IStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'> {
|
||||||
divideType?: 'space' | 'border',
|
divideType?: 'space' | 'border',
|
||||||
/** Whether to display ads. */
|
/** Whether to display ads. */
|
||||||
showAds?: boolean,
|
showAds?: boolean,
|
||||||
|
/** Whether to show group information. */
|
||||||
|
showGroup?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Feed of statuses, built atop ScrollableList. */
|
/** Feed of statuses, built atop ScrollableList. */
|
||||||
|
@ -59,6 +61,7 @@ const StatusList: React.FC<IStatusList> = ({
|
||||||
isLoading,
|
isLoading,
|
||||||
isPartial,
|
isPartial,
|
||||||
showAds = false,
|
showAds = false,
|
||||||
|
showGroup = true,
|
||||||
...other
|
...other
|
||||||
}) => {
|
}) => {
|
||||||
const { data: ads } = useAds();
|
const { data: ads } = useAds();
|
||||||
|
@ -135,6 +138,7 @@ const StatusList: React.FC<IStatusList> = ({
|
||||||
onMoveUp={handleMoveUp}
|
onMoveUp={handleMoveUp}
|
||||||
onMoveDown={handleMoveDown}
|
onMoveDown={handleMoveDown}
|
||||||
contextType={timelineId}
|
contextType={timelineId}
|
||||||
|
showGroup={showGroup}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -167,6 +171,7 @@ const StatusList: React.FC<IStatusList> = ({
|
||||||
onMoveUp={handleMoveUp}
|
onMoveUp={handleMoveUp}
|
||||||
onMoveDown={handleMoveDown}
|
onMoveDown={handleMoveDown}
|
||||||
contextType={timelineId}
|
contextType={timelineId}
|
||||||
|
showGroup={showGroup}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
|
@ -26,6 +26,7 @@ import { Card, Stack, Text } from './ui';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
Account as AccountEntity,
|
Account as AccountEntity,
|
||||||
|
Group as GroupEntity,
|
||||||
Status as StatusEntity,
|
Status as StatusEntity,
|
||||||
} from 'soapbox/types/entities';
|
} from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
@ -51,6 +52,7 @@ export interface IStatus {
|
||||||
hideActionBar?: boolean,
|
hideActionBar?: boolean,
|
||||||
hoverable?: boolean,
|
hoverable?: boolean,
|
||||||
variant?: 'default' | 'rounded',
|
variant?: 'default' | 'rounded',
|
||||||
|
showGroup?: boolean,
|
||||||
withDismiss?: boolean,
|
withDismiss?: boolean,
|
||||||
accountAction?: React.ReactElement,
|
accountAction?: React.ReactElement,
|
||||||
}
|
}
|
||||||
|
@ -71,6 +73,7 @@ const Status: React.FC<IStatus> = (props) => {
|
||||||
unread,
|
unread,
|
||||||
hideActionBar,
|
hideActionBar,
|
||||||
variant = 'rounded',
|
variant = 'rounded',
|
||||||
|
showGroup = true,
|
||||||
withDismiss,
|
withDismiss,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
@ -90,6 +93,7 @@ const Status: React.FC<IStatus> = (props) => {
|
||||||
const actualStatus = getActualStatus(status);
|
const actualStatus = getActualStatus(status);
|
||||||
const isReblog = status.reblog && typeof status.reblog === 'object';
|
const isReblog = status.reblog && typeof status.reblog === 'object';
|
||||||
const statusUrl = `/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`;
|
const statusUrl = `/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`;
|
||||||
|
const group = actualStatus.group as GroupEntity | null;
|
||||||
|
|
||||||
// Track height changes we know about to compensate scrolling.
|
// Track height changes we know about to compensate scrolling.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -244,6 +248,25 @@ const Status: React.FC<IStatus> = (props) => {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
} else if (showGroup && group) {
|
||||||
|
return (
|
||||||
|
<StatusInfo
|
||||||
|
avatarSize={avatarSize}
|
||||||
|
to={`/groups/${group.id}`}
|
||||||
|
icon={<Icon src={require('@tabler/icons/circles.svg')} className='text-gray-600 dark:text-gray-400' />}
|
||||||
|
text={
|
||||||
|
<Text size='xs' theme='muted' weight='medium'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='status.group'
|
||||||
|
defaultMessage='Posted in {group}'
|
||||||
|
values={{ group: (
|
||||||
|
<span dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||||
|
) }}
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -348,6 +371,7 @@ const Status: React.FC<IStatus> = (props) => {
|
||||||
showEdit={!!actualStatus.edited_at}
|
showEdit={!!actualStatus.edited_at}
|
||||||
showProfileHoverCard={hoverable}
|
showProfileHoverCard={hoverable}
|
||||||
withLinkToProfile={hoverable}
|
withLinkToProfile={hoverable}
|
||||||
|
approvalStatus={actualStatus.approval_status}
|
||||||
avatarSize={avatarSize}
|
avatarSize={avatarSize}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
@ -40,6 +40,8 @@ interface IModal {
|
||||||
confirmationText?: React.ReactNode,
|
confirmationText?: React.ReactNode,
|
||||||
/** Confirmation button theme. */
|
/** Confirmation button theme. */
|
||||||
confirmationTheme?: ButtonThemes,
|
confirmationTheme?: ButtonThemes,
|
||||||
|
/** Whether to use full width style for confirmation button. */
|
||||||
|
confirmationFullWidth?: boolean,
|
||||||
/** Callback when the modal is closed. */
|
/** Callback when the modal is closed. */
|
||||||
onClose?: () => void,
|
onClose?: () => void,
|
||||||
/** Callback when the secondary action is chosen. */
|
/** Callback when the secondary action is chosen. */
|
||||||
|
@ -66,6 +68,7 @@ const Modal: React.FC<IModal> = ({
|
||||||
confirmationDisabled,
|
confirmationDisabled,
|
||||||
confirmationText,
|
confirmationText,
|
||||||
confirmationTheme,
|
confirmationTheme,
|
||||||
|
confirmationFullWidth,
|
||||||
onClose,
|
onClose,
|
||||||
secondaryAction,
|
secondaryAction,
|
||||||
secondaryDisabled = false,
|
secondaryDisabled = false,
|
||||||
|
@ -118,7 +121,7 @@ const Modal: React.FC<IModal> = ({
|
||||||
|
|
||||||
{confirmationAction && (
|
{confirmationAction && (
|
||||||
<HStack className='mt-5' justifyContent='between' data-testid='modal-actions'>
|
<HStack className='mt-5' justifyContent='between' data-testid='modal-actions'>
|
||||||
<div className='flex-grow'>
|
<div className={classNames({ 'flex-grow': !confirmationFullWidth })}>
|
||||||
{cancelAction && (
|
{cancelAction && (
|
||||||
<Button
|
<Button
|
||||||
theme='tertiary'
|
theme='tertiary'
|
||||||
|
@ -129,7 +132,7 @@ const Modal: React.FC<IModal> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<HStack space={2}>
|
<HStack space={2} className={classNames({ 'flex-grow': confirmationFullWidth })}>
|
||||||
{secondaryAction && (
|
{secondaryAction && (
|
||||||
<Button
|
<Button
|
||||||
theme='secondary'
|
theme='secondary'
|
||||||
|
@ -145,6 +148,7 @@ const Modal: React.FC<IModal> = ({
|
||||||
onClick={confirmationAction}
|
onClick={confirmationAction}
|
||||||
disabled={confirmationDisabled}
|
disabled={confirmationDisabled}
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
|
block={confirmationFullWidth}
|
||||||
>
|
>
|
||||||
{confirmationText}
|
{confirmationText}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
import React, { useCallback } from 'react';
|
||||||
|
|
||||||
|
import GroupCard from 'soapbox/components/group-card';
|
||||||
|
import { useAppSelector } from 'soapbox/hooks';
|
||||||
|
import { makeGetGroup } from 'soapbox/selectors';
|
||||||
|
|
||||||
|
interface IGroupContainer {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupContainer: React.FC<IGroupContainer> = (props) => {
|
||||||
|
const { id, ...rest } = props;
|
||||||
|
|
||||||
|
const getGroup = useCallback(makeGetGroup(), []);
|
||||||
|
const group = useAppSelector(state => getGroup(state, id));
|
||||||
|
|
||||||
|
if (group) {
|
||||||
|
return <GroupCard group={group} {...rest} />;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupContainer;
|
|
@ -37,7 +37,7 @@ const Welcome = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack className='py-20 px-4 sm:px-0 h-full overflow-y-auto' data-testid='chats-welcome'>
|
<Stack className='py-20 px-4 sm:px-0 h-full overflow-y-auto' data-testid='chats-welcome'>
|
||||||
<div className='w-full sm:w-3/5 xl:w-2/5 mx-auto mb-10'>
|
<div className='w-full sm:w-3/5 xl:w-2/5 mx-auto mb-2.5'>
|
||||||
<Text align='center' weight='bold' className='mb-6 text-2xl md:text-3xl leading-8'>
|
<Text align='center' weight='bold' className='mb-6 text-2xl md:text-3xl leading-8'>
|
||||||
{intl.formatMessage(messages.title, { br: <br /> })}
|
{intl.formatMessage(messages.title, { br: <br /> })}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
|
@ -62,9 +62,10 @@ interface IComposeForm<ID extends string> {
|
||||||
autoFocus?: boolean,
|
autoFocus?: boolean,
|
||||||
clickableAreaRef?: React.RefObject<HTMLDivElement>,
|
clickableAreaRef?: React.RefObject<HTMLDivElement>,
|
||||||
event?: string,
|
event?: string,
|
||||||
|
group?: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickableAreaRef, event }: IComposeForm<ID>) => {
|
const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickableAreaRef, event, group }: IComposeForm<ID>) => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
@ -77,7 +78,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
const scheduledStatusCount = useAppSelector((state) => state.scheduled_statuses.size);
|
const scheduledStatusCount = useAppSelector((state) => state.scheduled_statuses.size);
|
||||||
const features = useFeatures();
|
const features = useFeatures();
|
||||||
|
|
||||||
const { text, suggestions, spoiler, spoiler_text: spoilerText, privacy, focusDate, caretPosition, is_submitting: isSubmitting, is_changing_upload: isChangingUpload, is_uploading: isUploading, schedule: scheduledAt } = compose;
|
const { text, suggestions, spoiler, spoiler_text: spoilerText, privacy, focusDate, caretPosition, is_submitting: isSubmitting, is_changing_upload: isChangingUpload, is_uploading: isUploading, schedule: scheduledAt, group_id: groupId } = compose;
|
||||||
const prevSpoiler = usePrevious(spoiler);
|
const prevSpoiler = usePrevious(spoiler);
|
||||||
|
|
||||||
const hasPoll = !!compose.poll;
|
const hasPoll = !!compose.poll;
|
||||||
|
@ -227,7 +228,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
{features.media && <UploadButtonContainer composeId={id} />}
|
{features.media && <UploadButtonContainer composeId={id} />}
|
||||||
<EmojiPickerDropdown onPickEmoji={handleEmojiPick} />
|
<EmojiPickerDropdown onPickEmoji={handleEmojiPick} />
|
||||||
{features.polls && <PollButton composeId={id} />}
|
{features.polls && <PollButton composeId={id} />}
|
||||||
{features.privacyScopes && <PrivacyDropdown composeId={id} />}
|
{features.privacyScopes && !group && !groupId && <PrivacyDropdown composeId={id} />}
|
||||||
{features.scheduledStatuses && <ScheduleButton composeId={id} />}
|
{features.scheduledStatuses && <ScheduleButton composeId={id} />}
|
||||||
{features.spoilers && <SpoilerButton composeId={id} />}
|
{features.spoilers && <SpoilerButton composeId={id} />}
|
||||||
{features.richText && <MarkdownButton composeId={id} />}
|
{features.richText && <MarkdownButton composeId={id} />}
|
||||||
|
@ -270,7 +271,7 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack className='w-full' space={4} ref={formRef} onClick={handleClick} element='form' onSubmit={handleSubmit}>
|
<Stack className='w-full' space={4} ref={formRef} onClick={handleClick} element='form' onSubmit={handleSubmit}>
|
||||||
{scheduledStatusCount > 0 && !event && (
|
{scheduledStatusCount > 0 && !event && !group && (
|
||||||
<Warning
|
<Warning
|
||||||
message={(
|
message={(
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
|
@ -291,9 +292,9 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
|
|
||||||
<WarningContainer composeId={id} />
|
<WarningContainer composeId={id} />
|
||||||
|
|
||||||
{!shouldCondense && !event && <ReplyIndicatorContainer composeId={id} />}
|
{!shouldCondense && !event && !group && <ReplyIndicatorContainer composeId={id} />}
|
||||||
|
|
||||||
{!shouldCondense && !event && <ReplyMentions composeId={id} />}
|
{!shouldCondense && !event && !group && <ReplyMentions composeId={id} />}
|
||||||
|
|
||||||
<AutosuggestTextarea
|
<AutosuggestTextarea
|
||||||
ref={(isModalOpen && shouldCondense) ? undefined : autosuggestTextareaRef}
|
ref={(isModalOpen && shouldCondense) ? undefined : autosuggestTextareaRef}
|
||||||
|
@ -349,8 +350,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
|
||||||
|
|
||||||
<Button type='submit' theme='primary' text={publishText} icon={publishIcon} disabled={disabledButton} />
|
<Button type='submit' theme='primary' text={publishText} icon={publishIcon} disabled={disabledButton} />
|
||||||
</HStack>
|
</HStack>
|
||||||
{/* <HStack alignItems='center' space={4}>
|
|
||||||
</HStack> */}
|
|
||||||
</div>
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|
|
@ -9,11 +9,13 @@ import IconButton from 'soapbox/components/icon-button';
|
||||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||||
import { HStack, Tabs, Text } from 'soapbox/components/ui';
|
import { HStack, Tabs, Text } from 'soapbox/components/ui';
|
||||||
import AccountContainer from 'soapbox/containers/account-container';
|
import AccountContainer from 'soapbox/containers/account-container';
|
||||||
|
import GroupContainer from 'soapbox/containers/group-container';
|
||||||
import StatusContainer from 'soapbox/containers/status-container';
|
import StatusContainer from 'soapbox/containers/status-container';
|
||||||
import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder-account';
|
import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder-account';
|
||||||
|
import PlaceholderGroupCard from 'soapbox/features/placeholder/components/placeholder-group-card';
|
||||||
import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder-hashtag';
|
import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder-hashtag';
|
||||||
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status';
|
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status';
|
||||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||||
|
|
||||||
import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||||
import type { VirtuosoHandle } from 'react-virtuoso';
|
import type { VirtuosoHandle } from 'react-virtuoso';
|
||||||
|
@ -22,6 +24,7 @@ import type { SearchFilter } from 'soapbox/reducers/search';
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
accounts: { id: 'search_results.accounts', defaultMessage: 'People' },
|
accounts: { id: 'search_results.accounts', defaultMessage: 'People' },
|
||||||
statuses: { id: 'search_results.statuses', defaultMessage: 'Posts' },
|
statuses: { id: 'search_results.statuses', defaultMessage: 'Posts' },
|
||||||
|
groups: { id: 'search_results.groups', defaultMessage: 'Groups' },
|
||||||
hashtags: { id: 'search_results.hashtags', defaultMessage: 'Hashtags' },
|
hashtags: { id: 'search_results.hashtags', defaultMessage: 'Hashtags' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -30,6 +33,7 @@ const SearchResults = () => {
|
||||||
|
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const features = useFeatures();
|
||||||
|
|
||||||
const value = useAppSelector((state) => state.search.submittedValue);
|
const value = useAppSelector((state) => state.search.submittedValue);
|
||||||
const results = useAppSelector((state) => state.search.results);
|
const results = useAppSelector((state) => state.search.results);
|
||||||
|
@ -48,7 +52,8 @@ const SearchResults = () => {
|
||||||
const selectFilter = (newActiveFilter: SearchFilter) => dispatch(setFilter(newActiveFilter));
|
const selectFilter = (newActiveFilter: SearchFilter) => dispatch(setFilter(newActiveFilter));
|
||||||
|
|
||||||
const renderFilterBar = () => {
|
const renderFilterBar = () => {
|
||||||
const items = [
|
const items = [];
|
||||||
|
items.push(
|
||||||
{
|
{
|
||||||
text: intl.formatMessage(messages.accounts),
|
text: intl.formatMessage(messages.accounts),
|
||||||
action: () => selectFilter('accounts'),
|
action: () => selectFilter('accounts'),
|
||||||
|
@ -59,12 +64,23 @@ const SearchResults = () => {
|
||||||
action: () => selectFilter('statuses'),
|
action: () => selectFilter('statuses'),
|
||||||
name: 'statuses',
|
name: 'statuses',
|
||||||
},
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (features.groups) items.push(
|
||||||
|
{
|
||||||
|
text: intl.formatMessage(messages.groups),
|
||||||
|
action: () => selectFilter('groups'),
|
||||||
|
name: 'groups',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
items.push(
|
||||||
{
|
{
|
||||||
text: intl.formatMessage(messages.hashtags),
|
text: intl.formatMessage(messages.hashtags),
|
||||||
action: () => selectFilter('hashtags'),
|
action: () => selectFilter('hashtags'),
|
||||||
name: 'hashtags',
|
name: 'hashtags',
|
||||||
},
|
},
|
||||||
];
|
);
|
||||||
|
|
||||||
return <Tabs items={items} activeItem={selectedFilter} />;
|
return <Tabs items={items} activeItem={selectedFilter} />;
|
||||||
};
|
};
|
||||||
|
@ -170,6 +186,31 @@ const SearchResults = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedFilter === 'groups') {
|
||||||
|
hasMore = results.groupsHasMore;
|
||||||
|
loaded = results.groupsLoaded;
|
||||||
|
placeholderComponent = PlaceholderGroupCard;
|
||||||
|
|
||||||
|
if (results.groups && results.groups.size > 0) {
|
||||||
|
searchResults = results.groups.map((groupId: string) => (
|
||||||
|
<GroupContainer id={groupId} />
|
||||||
|
));
|
||||||
|
resultsIds = results.groups;
|
||||||
|
} else if (!submitted && trendingStatuses && !trendingStatuses.isEmpty()) {
|
||||||
|
searchResults = null;
|
||||||
|
} else if (loaded) {
|
||||||
|
noResultsMessage = (
|
||||||
|
<div className='empty-column-indicator'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='empty_column.search.groups'
|
||||||
|
defaultMessage='There are no groups results for "{term}"'
|
||||||
|
values={{ term: value }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedFilter === 'hashtags') {
|
if (selectedFilter === 'hashtags') {
|
||||||
hasMore = results.hashtagsHasMore;
|
hasMore = results.hashtagsHasMore;
|
||||||
loaded = results.hashtagsLoaded;
|
loaded = results.hashtagsLoaded;
|
||||||
|
|
|
@ -3,10 +3,10 @@ import React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { getSettings } from 'soapbox/actions/settings';
|
import { getSettings } from 'soapbox/actions/settings';
|
||||||
|
import Account from 'soapbox/components/account';
|
||||||
import Badge from 'soapbox/components/badge';
|
import Badge from 'soapbox/components/badge';
|
||||||
import RelativeTimestamp from 'soapbox/components/relative-timestamp';
|
import RelativeTimestamp from 'soapbox/components/relative-timestamp';
|
||||||
import { Stack, Text } from 'soapbox/components/ui';
|
import { Stack, Text } from 'soapbox/components/ui';
|
||||||
import AccountContainer from 'soapbox/containers/account-container';
|
|
||||||
import ActionButton from 'soapbox/features/ui/components/action-button';
|
import ActionButton from 'soapbox/features/ui/components/action-button';
|
||||||
import { useAppSelector } from 'soapbox/hooks';
|
import { useAppSelector } from 'soapbox/hooks';
|
||||||
import { makeGetAccount } from 'soapbox/selectors';
|
import { makeGetAccount } from 'soapbox/selectors';
|
||||||
|
@ -51,8 +51,8 @@ const AccountCard: React.FC<IAccountCard> = ({ id }) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Stack space={4} className='p-3'>
|
<Stack space={4} className='p-3'>
|
||||||
<AccountContainer
|
<Account
|
||||||
id={account.id}
|
account={account}
|
||||||
withRelationship={false}
|
withRelationship={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,205 @@
|
||||||
|
import { List as ImmutableList } from 'immutable';
|
||||||
|
import React from 'react';
|
||||||
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { joinGroup, leaveGroup } from 'soapbox/actions/groups';
|
||||||
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
|
import StillImage from 'soapbox/components/still-image';
|
||||||
|
import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
|
||||||
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
import { normalizeAttachment } from 'soapbox/normalizers';
|
||||||
|
import { isDefaultHeader } from 'soapbox/utils/accounts';
|
||||||
|
|
||||||
|
import type { Group } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
header: { id: 'group.header.alt', defaultMessage: 'Group header' },
|
||||||
|
confirmationHeading: { id: 'confirmations.leave_group.heading', defaultMessage: 'Leave group' },
|
||||||
|
confirmationMessage: { id: 'confirmations.leave_group.message', defaultMessage: 'You are about to leave the group. Do you want to continue?' },
|
||||||
|
confirmationConfirm: { id: 'confirmations.leave_group.confirm', defaultMessage: 'Leave' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IGroupHeader {
|
||||||
|
group?: Group | false | null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return (
|
||||||
|
<div className='-mt-4 -mx-4'>
|
||||||
|
<div>
|
||||||
|
<div className='relative h-32 w-full lg:h-48 md:rounded-t-xl bg-gray-200 dark:bg-gray-900/50' />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='px-4 sm:px-6'>
|
||||||
|
<HStack alignItems='bottom' space={5} className='-mt-12'>
|
||||||
|
<div className='flex relative'>
|
||||||
|
<div
|
||||||
|
className='h-24 w-24 bg-gray-400 rounded-full ring-4 ring-white dark:ring-gray-800'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onJoinGroup = () => dispatch(joinGroup(group.id));
|
||||||
|
|
||||||
|
const onLeaveGroup = () =>
|
||||||
|
dispatch(openModal('CONFIRM', {
|
||||||
|
heading: intl.formatMessage(messages.confirmationHeading),
|
||||||
|
message: intl.formatMessage(messages.confirmationMessage),
|
||||||
|
confirm: intl.formatMessage(messages.confirmationConfirm),
|
||||||
|
onConfirm: () => dispatch(leaveGroup(group.id)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const onAvatarClick = () => {
|
||||||
|
const avatar = normalizeAttachment({
|
||||||
|
type: 'image',
|
||||||
|
url: group.avatar,
|
||||||
|
});
|
||||||
|
dispatch(openModal('MEDIA', { media: ImmutableList.of(avatar), index: 0 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAvatarClick: React.MouseEventHandler = (e) => {
|
||||||
|
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
onAvatarClick();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onHeaderClick = () => {
|
||||||
|
const header = normalizeAttachment({
|
||||||
|
type: 'image',
|
||||||
|
url: group.header,
|
||||||
|
});
|
||||||
|
dispatch(openModal('MEDIA', { media: ImmutableList.of(header), index: 0 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHeaderClick: React.MouseEventHandler = (e) => {
|
||||||
|
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
onHeaderClick();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderHeader = () => {
|
||||||
|
let header: React.ReactNode;
|
||||||
|
|
||||||
|
if (group.header) {
|
||||||
|
header = (
|
||||||
|
<StillImage
|
||||||
|
src={group.header}
|
||||||
|
alt={intl.formatMessage(messages.header)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isDefaultHeader(group.header)) {
|
||||||
|
header = (
|
||||||
|
<a href={group.header} onClick={handleHeaderClick} target='_blank'>
|
||||||
|
{header}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return header;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeActionButton = () => {
|
||||||
|
if (!group.relationship || !group.relationship.member) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
theme='primary'
|
||||||
|
onClick={onJoinGroup}
|
||||||
|
>
|
||||||
|
{group.locked ? <FormattedMessage id='group.request_join' defaultMessage='Request to join group' /> : <FormattedMessage id='group.join' defaultMessage='Join group' />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.relationship.requested) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
theme='secondary'
|
||||||
|
onClick={onLeaveGroup}
|
||||||
|
>
|
||||||
|
<FormattedMessage id='group.cancel_request' defaultMessage='Cancel request' />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.relationship?.role === 'admin') {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
theme='secondary'
|
||||||
|
to={`/groups/${group.id}/manage`}
|
||||||
|
>
|
||||||
|
<FormattedMessage id='group.manage' defaultMessage='Manage group' />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
theme='secondary'
|
||||||
|
onClick={onLeaveGroup}
|
||||||
|
>
|
||||||
|
<FormattedMessage id='group.leave' defaultMessage='Leave group' />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionButton = makeActionButton();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='-mt-4 -mx-4'>
|
||||||
|
<div className='relative'>
|
||||||
|
<div className='relative flex flex-col justify-center h-32 w-full lg:h-[200px] md:rounded-t-xl bg-gray-200 dark:bg-gray-900/50 overflow-hidden isolate'>
|
||||||
|
{renderHeader()}
|
||||||
|
</div>
|
||||||
|
<div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
|
||||||
|
<a href={group.avatar} onClick={handleAvatarClick} target='_blank'>
|
||||||
|
<Avatar className='ring-[3px] ring-white dark:ring-primary-900' src={group.avatar} size={72} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Stack className='p-3 pt-12' alignItems='center' space={2}>
|
||||||
|
<Text className='mb-1' size='xl' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||||
|
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
|
||||||
|
{group.relationship?.role === 'admin' ? (
|
||||||
|
<HStack space={1} alignItems='center'>
|
||||||
|
<Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} />
|
||||||
|
<span><FormattedMessage id='group.role.admin' defaultMessage='Admin' /></span>
|
||||||
|
</HStack>
|
||||||
|
) : group.relationship?.role === 'moderator' && (
|
||||||
|
<HStack space={1} alignItems='center'>
|
||||||
|
<Icon className='h-4 w-4' src={require('@tabler/icons/gavel.svg')} />
|
||||||
|
<span><FormattedMessage id='group.role.moderator' defaultMessage='Moderator' /></span>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
{group.locked ? (
|
||||||
|
<HStack space={1} alignItems='center'>
|
||||||
|
<Icon className='h-4 w-4' src={require('@tabler/icons/lock.svg')} />
|
||||||
|
<span><FormattedMessage id='group.privacy.locked' defaultMessage='Private' /></span>
|
||||||
|
</HStack>
|
||||||
|
) : (
|
||||||
|
<HStack space={1} alignItems='center'>
|
||||||
|
<Icon className='h-4 w-4' src={require('@tabler/icons/world.svg')} />
|
||||||
|
<span><FormattedMessage id='group.privacy.public' defaultMessage='Public' /></span>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
<Text theme='muted' dangerouslySetInnerHTML={{ __html: group.note_emojified }} />
|
||||||
|
{actionButton}
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupHeader;
|
|
@ -0,0 +1,104 @@
|
||||||
|
import React, { useCallback, useEffect } from 'react';
|
||||||
|
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { fetchGroup, fetchGroupBlocks, groupUnblock } from 'soapbox/actions/groups';
|
||||||
|
import Account from 'soapbox/components/account';
|
||||||
|
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||||
|
import { Button, Column, HStack, Spinner } from 'soapbox/components/ui';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
import { makeGetAccount, makeGetGroup } from 'soapbox/selectors';
|
||||||
|
import toast from 'soapbox/toast';
|
||||||
|
|
||||||
|
import ColumnForbidden from '../ui/components/column-forbidden';
|
||||||
|
|
||||||
|
type RouteParams = { id: string };
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
heading: { id: 'column.group_blocked_members', defaultMessage: 'Blocked members' },
|
||||||
|
unblock: { id: 'group.group_mod_unblock', defaultMessage: 'Unblock' },
|
||||||
|
unblocked: { id: 'group.group_mod_unblock.success', defaultMessage: 'Unblocked @{name} from group' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IBlockedMember {
|
||||||
|
accountId: string
|
||||||
|
groupId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const BlockedMember: React.FC<IBlockedMember> = ({ accountId, groupId }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const getAccount = useCallback(makeGetAccount(), []);
|
||||||
|
|
||||||
|
const account = useAppSelector((state) => getAccount(state, accountId));
|
||||||
|
|
||||||
|
if (!account) return null;
|
||||||
|
|
||||||
|
const handleUnblock = () =>
|
||||||
|
dispatch(groupUnblock(groupId, accountId)).then(() => {
|
||||||
|
toast.success(intl.formatMessage(messages.unblocked, { name: account.acct }));
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HStack space={1} alignItems='center' justifyContent='between' className='p-2.5'>
|
||||||
|
<div className='w-full'>
|
||||||
|
<Account account={account} withRelationship={false} />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
theme='danger'
|
||||||
|
size='sm'
|
||||||
|
text={intl.formatMessage(messages.unblock)}
|
||||||
|
onClick={handleUnblock}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IGroupBlockedMembers {
|
||||||
|
params: RouteParams
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupBlockedMembers: React.FC<IGroupBlockedMembers> = ({ params }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const id = params?.id || '';
|
||||||
|
|
||||||
|
const getGroup = useCallback(makeGetGroup(), []);
|
||||||
|
const group = useAppSelector(state => getGroup(state, id));
|
||||||
|
const accountIds = useAppSelector((state) => state.user_lists.group_blocks.get(id)?.items);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!group) dispatch(fetchGroup(id));
|
||||||
|
dispatch(fetchGroupBlocks(id));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (!group || !group.relationship || !accountIds) {
|
||||||
|
return (
|
||||||
|
<Column label={intl.formatMessage(messages.heading)}>
|
||||||
|
<Spinner />
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!group.relationship.role || !['admin', 'moderator'].includes(group.relationship.role)) {
|
||||||
|
return (<ColumnForbidden />);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyMessage = <FormattedMessage id='empty_column.group_blocks' defaultMessage="The group hasn't blocked any users yet." />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column label={intl.formatMessage(messages.heading)} backHref={`/groups/${id}/manage`}>
|
||||||
|
<ScrollableList
|
||||||
|
scrollKey='group_blocks'
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
|
>
|
||||||
|
{accountIds.map((accountId) =>
|
||||||
|
<BlockedMember key={accountId} accountId={accountId} groupId={id} />,
|
||||||
|
)}
|
||||||
|
</ScrollableList>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupBlockedMembers;
|
|
@ -0,0 +1,285 @@
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
|
import React, { useCallback, useEffect } from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { expandGroupMemberships, fetchGroup, fetchGroupMemberships, groupBlock, groupDemoteAccount, groupKick, groupPromoteAccount } from 'soapbox/actions/groups';
|
||||||
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
|
import Account from 'soapbox/components/account';
|
||||||
|
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||||
|
import { CardHeader, CardTitle, HStack, IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui';
|
||||||
|
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
import { makeGetAccount } from 'soapbox/selectors';
|
||||||
|
import toast from 'soapbox/toast';
|
||||||
|
|
||||||
|
import PlaceholderAccount from '../placeholder/components/placeholder-account';
|
||||||
|
|
||||||
|
import type { Menu as MenuType } from 'soapbox/components/dropdown-menu';
|
||||||
|
import type { GroupRole, List } from 'soapbox/reducers/group-memberships';
|
||||||
|
import type { GroupRelationship } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
type RouteParams = { id: string };
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
adminSubheading: { id: 'group.admin_subheading', defaultMessage: 'Group administrators' },
|
||||||
|
moderatorSubheading: { id: 'group.moderator_subheading', defaultMessage: 'Group moderators' },
|
||||||
|
userSubheading: { id: 'group.user_subheading', defaultMessage: 'Users' },
|
||||||
|
groupModKick: { id: 'group.group_mod_kick', defaultMessage: 'Kick @{name} from group' },
|
||||||
|
groupModBlock: { id: 'group.group_mod_block', defaultMessage: 'Block @{name} from group' },
|
||||||
|
groupModPromoteAdmin: { id: 'group.group_mod_promote_admin', defaultMessage: 'Promote @{name} to group administrator' },
|
||||||
|
groupModPromoteMod: { id: 'group.group_mod_promote_mod', defaultMessage: 'Promote @{name} to group moderator' },
|
||||||
|
groupModDemote: { id: 'group.group_mod_demote', defaultMessage: 'Demote @{name}' },
|
||||||
|
kickFromGroupMessage: { id: 'confirmations.kick_from_group.message', defaultMessage: 'Are you sure you want to kick @{name} from this group?' },
|
||||||
|
kickConfirm: { id: 'confirmations.kick_from_group.confirm', defaultMessage: 'Kick' },
|
||||||
|
blockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to block @{name} from interacting with this group?' },
|
||||||
|
blockConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Block' },
|
||||||
|
promoteConfirmMessage: { id: 'confirmations.promote_in_group.message', defaultMessage: 'Are you sure you want to promote @{name}? You will not be able to demote them.' },
|
||||||
|
promoteConfirm: { id: 'confirmations.promote_in_group.confirm', defaultMessage: 'Promote' },
|
||||||
|
kicked: { id: 'group.group_mod_kick.success', defaultMessage: 'Kicked @{name} from group' },
|
||||||
|
blocked: { id: 'group.group_mod_block.success', defaultMessage: 'Blocked @{name} from group' },
|
||||||
|
promotedToAdmin: { id: 'group.group_mod_promote_admin.success', defaultMessage: 'Promoted @{name} to group administrator' },
|
||||||
|
promotedToMod: { id: 'group.group_mod_promote_mod.success', defaultMessage: 'Promoted @{name} to group moderator' },
|
||||||
|
demotedToUser: { id: 'group.group_mod_demote.success', defaultMessage: 'Demoted @{name} to group user' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IGroupMember {
|
||||||
|
accountId: string
|
||||||
|
accountRole: GroupRole
|
||||||
|
groupId: string
|
||||||
|
relationship?: GroupRelationship
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupMember: React.FC<IGroupMember> = ({ accountId, accountRole, groupId, relationship }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const getAccount = useCallback(makeGetAccount(), []);
|
||||||
|
|
||||||
|
const account = useAppSelector((state) => getAccount(state, accountId));
|
||||||
|
|
||||||
|
if (!account) return null;
|
||||||
|
|
||||||
|
const handleKickFromGroup = () => {
|
||||||
|
dispatch(openModal('CONFIRM', {
|
||||||
|
message: intl.formatMessage(messages.kickFromGroupMessage, { name: account.username }),
|
||||||
|
confirm: intl.formatMessage(messages.kickConfirm),
|
||||||
|
onConfirm: () => dispatch(groupKick(groupId, account.id)).then(() =>
|
||||||
|
toast.success(intl.formatMessage(messages.kicked, { name: account.acct })),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlockFromGroup = () => {
|
||||||
|
dispatch(openModal('CONFIRM', {
|
||||||
|
message: intl.formatMessage(messages.blockFromGroupMessage, { name: account.username }),
|
||||||
|
confirm: intl.formatMessage(messages.blockConfirm),
|
||||||
|
onConfirm: () => dispatch(groupBlock(groupId, account.id)).then(() =>
|
||||||
|
toast.success(intl.formatMessage(messages.blocked, { name: account.acct })),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPromote = (role: 'admin' | 'moderator', warning?: boolean) => {
|
||||||
|
if (warning) {
|
||||||
|
return dispatch(openModal('CONFIRM', {
|
||||||
|
message: intl.formatMessage(messages.promoteConfirmMessage, { name: account.username }),
|
||||||
|
confirm: intl.formatMessage(messages.promoteConfirm),
|
||||||
|
onConfirm: () => dispatch(groupPromoteAccount(groupId, account.id, role)).then(() =>
|
||||||
|
toast.success(intl.formatMessage(role === 'admin' ? messages.promotedToAdmin : messages.promotedToMod, { name: account.acct })),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
return dispatch(groupPromoteAccount(groupId, account.id, role)).then(() =>
|
||||||
|
toast.success(intl.formatMessage(role === 'admin' ? messages.promotedToAdmin : messages.promotedToMod, { name: account.acct })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePromoteToGroupAdmin = () => {
|
||||||
|
onPromote('admin', true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePromoteToGroupMod = () => {
|
||||||
|
onPromote('moderator', relationship!.role === 'moderator');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDemote = () => {
|
||||||
|
dispatch(groupDemoteAccount(groupId, account.id, 'user')).then(() =>
|
||||||
|
toast.success(intl.formatMessage(messages.demotedToUser, { name: account.acct })),
|
||||||
|
).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeMenu = () => {
|
||||||
|
const menu: MenuType = [];
|
||||||
|
|
||||||
|
if (!relationship || !relationship.role) return menu;
|
||||||
|
|
||||||
|
if (['admin', 'moderator'].includes(relationship.role) && ['moderator', 'user'].includes(accountRole) && accountRole !== relationship.role) {
|
||||||
|
menu.push({
|
||||||
|
text: intl.formatMessage(messages.groupModKick, { name: account.username }),
|
||||||
|
icon: require('@tabler/icons/user-minus.svg'),
|
||||||
|
action: handleKickFromGroup,
|
||||||
|
});
|
||||||
|
menu.push({
|
||||||
|
text: intl.formatMessage(messages.groupModBlock, { name: account.username }),
|
||||||
|
icon: require('@tabler/icons/ban.svg'),
|
||||||
|
action: handleBlockFromGroup,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relationship.role === 'admin' && accountRole !== 'admin' && account.acct === account.username) {
|
||||||
|
menu.push(null);
|
||||||
|
switch (accountRole) {
|
||||||
|
case 'moderator':
|
||||||
|
menu.push({
|
||||||
|
text: intl.formatMessage(messages.groupModPromoteAdmin, { name: account.username }),
|
||||||
|
icon: require('@tabler/icons/arrow-up-circle.svg'),
|
||||||
|
action: handlePromoteToGroupAdmin,
|
||||||
|
});
|
||||||
|
menu.push({
|
||||||
|
text: intl.formatMessage(messages.groupModDemote, { name: account.username }),
|
||||||
|
icon: require('@tabler/icons/arrow-down-circle.svg'),
|
||||||
|
action: handleDemote,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'user':
|
||||||
|
menu.push({
|
||||||
|
text: intl.formatMessage(messages.groupModPromoteMod, { name: account.username }),
|
||||||
|
icon: require('@tabler/icons/arrow-up-circle.svg'),
|
||||||
|
action: handlePromoteToGroupMod,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return menu;
|
||||||
|
};
|
||||||
|
|
||||||
|
const menu = makeMenu();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HStack space={1} alignItems='center' justifyContent='between' className='p-2.5'>
|
||||||
|
<div className='w-full'>
|
||||||
|
<Account account={account} withRelationship={false} />
|
||||||
|
</div>
|
||||||
|
{menu.length > 0 && (
|
||||||
|
<Menu>
|
||||||
|
<MenuButton
|
||||||
|
as={IconButton}
|
||||||
|
src={require('@tabler/icons/dots.svg')}
|
||||||
|
theme='outlined'
|
||||||
|
className='px-2'
|
||||||
|
iconClassName='w-4 h-4'
|
||||||
|
children={null}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MenuList className='w-56'>
|
||||||
|
{menu.map((menuItem, idx) => {
|
||||||
|
if (typeof menuItem?.text === 'undefined') {
|
||||||
|
return <MenuDivider key={idx} />;
|
||||||
|
} else {
|
||||||
|
const Comp = (menuItem.action ? MenuItem : MenuLink) as any;
|
||||||
|
const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.newTab ? '_blank' : '_self' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp key={idx} {...itemProps} className='group'>
|
||||||
|
<HStack space={3} alignItems='center'>
|
||||||
|
{menuItem.icon && (
|
||||||
|
<SvgIcon src={menuItem.icon} className='h-5 w-5 text-gray-400 flex-none group-hover:text-gray-500' />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='truncate'>{menuItem.text}</div>
|
||||||
|
</HStack>
|
||||||
|
</Comp>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</MenuList>
|
||||||
|
</Menu>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IGroupMembers {
|
||||||
|
params: RouteParams
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupMembers: React.FC<IGroupMembers> = (props) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const groupId = props.params.id;
|
||||||
|
|
||||||
|
const relationship = useAppSelector((state) => state.group_relationships.get(groupId));
|
||||||
|
const admins = useAppSelector((state) => state.group_memberships.admin.get(groupId));
|
||||||
|
const moderators = useAppSelector((state) => state.group_memberships.moderator.get(groupId));
|
||||||
|
const users = useAppSelector((state) => state.group_memberships.user.get(groupId));
|
||||||
|
|
||||||
|
const handleLoadMore = (role: 'admin' | 'moderator' | 'user') => {
|
||||||
|
dispatch(expandGroupMemberships(groupId, role));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadMoreAdmins = useCallback(debounce(() => {
|
||||||
|
handleLoadMore('admin');
|
||||||
|
}, 300, { leading: true }), []);
|
||||||
|
|
||||||
|
const handleLoadMoreModerators = useCallback(debounce(() => {
|
||||||
|
handleLoadMore('moderator');
|
||||||
|
}, 300, { leading: true }), []);
|
||||||
|
|
||||||
|
const handleLoadMoreUsers = useCallback(debounce(() => {
|
||||||
|
handleLoadMore('user');
|
||||||
|
}, 300, { leading: true }), []);
|
||||||
|
|
||||||
|
const renderMemberships = (memberships: List | undefined, role: GroupRole, handler: () => void) => {
|
||||||
|
if (!memberships?.isLoading && !memberships?.items.count()) return;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={role}>
|
||||||
|
<CardHeader className='mt-4'>
|
||||||
|
<CardTitle title={intl.formatMessage(messages[`${role}Subheading`])} />
|
||||||
|
</CardHeader>
|
||||||
|
<ScrollableList
|
||||||
|
scrollKey={`group_${role}s-${groupId}`}
|
||||||
|
hasMore={!!memberships?.next}
|
||||||
|
onLoadMore={handler}
|
||||||
|
isLoading={memberships?.isLoading}
|
||||||
|
showLoading={memberships?.isLoading && !memberships?.items?.count()}
|
||||||
|
placeholderComponent={PlaceholderAccount}
|
||||||
|
placeholderCount={3}
|
||||||
|
itemClassName='pb-4 last:pb-0'
|
||||||
|
>
|
||||||
|
{memberships?.items?.map(accountId => (
|
||||||
|
<GroupMember
|
||||||
|
key={accountId}
|
||||||
|
accountId={accountId}
|
||||||
|
accountRole={role}
|
||||||
|
groupId={groupId}
|
||||||
|
relationship={relationship}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollableList>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchGroup(groupId));
|
||||||
|
|
||||||
|
dispatch(fetchGroupMemberships(groupId, 'admin'));
|
||||||
|
dispatch(fetchGroupMemberships(groupId, 'moderator'));
|
||||||
|
dispatch(fetchGroupMemberships(groupId, 'user'));
|
||||||
|
}, [groupId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{renderMemberships(admins, 'admin', handleLoadMoreAdmins)}
|
||||||
|
{renderMemberships(moderators, 'moderator', handleLoadMoreModerators)}
|
||||||
|
{renderMemberships(users, 'user', handleLoadMoreUsers)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupMembers;
|
|
@ -0,0 +1,119 @@
|
||||||
|
import React, { useCallback, useEffect } from 'react';
|
||||||
|
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { authorizeGroupMembershipRequest, fetchGroup, fetchGroupMembershipRequests, rejectGroupMembershipRequest } from 'soapbox/actions/groups';
|
||||||
|
import Account from 'soapbox/components/account';
|
||||||
|
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||||
|
import { Button, Column, HStack, Spinner } from 'soapbox/components/ui';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
import { makeGetAccount, makeGetGroup } from 'soapbox/selectors';
|
||||||
|
import toast from 'soapbox/toast';
|
||||||
|
|
||||||
|
import ColumnForbidden from '../ui/components/column-forbidden';
|
||||||
|
|
||||||
|
type RouteParams = { id: string };
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
heading: { id: 'column.group_pending_requests', defaultMessage: 'Pending requests' },
|
||||||
|
authorize: { id: 'group.group_mod_authorize', defaultMessage: 'Accept' },
|
||||||
|
authorized: { id: 'group.group_mod_authorize.success', defaultMessage: 'Accepted @{name} to group' },
|
||||||
|
reject: { id: 'group.group_mod_reject', defaultMessage: 'Reject' },
|
||||||
|
rejected: { id: 'group.group_mod_reject.success', defaultMessage: 'Rejected @{name} from group' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IMembershipRequest {
|
||||||
|
accountId: string
|
||||||
|
groupId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const MembershipRequest: React.FC<IMembershipRequest> = ({ accountId, groupId }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const getAccount = useCallback(makeGetAccount(), []);
|
||||||
|
|
||||||
|
const account = useAppSelector((state) => getAccount(state, accountId));
|
||||||
|
|
||||||
|
if (!account) return null;
|
||||||
|
|
||||||
|
const handleAuthorize = () =>
|
||||||
|
dispatch(authorizeGroupMembershipRequest(groupId, accountId)).then(() => {
|
||||||
|
toast.success(intl.formatMessage(messages.authorized, { name: account.acct }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleReject = () =>
|
||||||
|
dispatch(rejectGroupMembershipRequest(groupId, accountId)).then(() => {
|
||||||
|
toast.success(intl.formatMessage(messages.rejected, { name: account.acct }));
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HStack space={1} alignItems='center' justifyContent='between' className='p-2.5'>
|
||||||
|
<div className='w-full'>
|
||||||
|
<Account account={account} withRelationship={false} />
|
||||||
|
</div>
|
||||||
|
<HStack space={2}>
|
||||||
|
<Button
|
||||||
|
theme='secondary'
|
||||||
|
size='sm'
|
||||||
|
text={intl.formatMessage(messages.authorize)}
|
||||||
|
onClick={handleAuthorize}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
theme='danger'
|
||||||
|
size='sm'
|
||||||
|
text={intl.formatMessage(messages.reject)}
|
||||||
|
onClick={handleReject}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IGroupMembershipRequests {
|
||||||
|
params: RouteParams
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupMembershipRequests: React.FC<IGroupMembershipRequests> = ({ params }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const id = params?.id || '';
|
||||||
|
|
||||||
|
const getGroup = useCallback(makeGetGroup(), []);
|
||||||
|
const group = useAppSelector(state => getGroup(state, id));
|
||||||
|
const accountIds = useAppSelector((state) => state.user_lists.membership_requests.get(id)?.items);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!group) dispatch(fetchGroup(id));
|
||||||
|
dispatch(fetchGroupMembershipRequests(id));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (!group || !group.relationship || !accountIds) {
|
||||||
|
return (
|
||||||
|
<Column label={intl.formatMessage(messages.heading)}>
|
||||||
|
<Spinner />
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!group.relationship.role || !['admin', 'moderator'].includes(group.relationship.role)) {
|
||||||
|
return (<ColumnForbidden />);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyMessage = <FormattedMessage id='empty_column.group_membership_requests' defaultMessage='There are no pending membership requests for this group.' />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column label={intl.formatMessage(messages.heading)} backHref={`/groups/${id}/manage`}>
|
||||||
|
<ScrollableList
|
||||||
|
scrollKey='group_membership_requests'
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
|
>
|
||||||
|
{accountIds.map((accountId) =>
|
||||||
|
<MembershipRequest key={accountId} accountId={accountId} groupId={id} />,
|
||||||
|
)}
|
||||||
|
</ScrollableList>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupMembershipRequests;
|
|
@ -0,0 +1,76 @@
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { groupCompose } from 'soapbox/actions/compose';
|
||||||
|
import { fetchGroup } from 'soapbox/actions/groups';
|
||||||
|
import { connectGroupStream } from 'soapbox/actions/streaming';
|
||||||
|
import { expandGroupTimeline } from 'soapbox/actions/timelines';
|
||||||
|
import { Avatar, HStack, Stack } from 'soapbox/components/ui';
|
||||||
|
import ComposeForm from 'soapbox/features/compose/components/compose-form';
|
||||||
|
import { useAppDispatch, useAppSelector, useOwnAccount } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import Timeline from '../ui/components/timeline';
|
||||||
|
|
||||||
|
type RouteParams = { id: string };
|
||||||
|
|
||||||
|
interface IGroupTimeline {
|
||||||
|
params: RouteParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
|
||||||
|
const account = useOwnAccount();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const groupId = props.params.id;
|
||||||
|
|
||||||
|
const relationship = useAppSelector((state) => state.group_relationships.get(groupId));
|
||||||
|
|
||||||
|
const handleLoadMore = (maxId: string) => {
|
||||||
|
dispatch(expandGroupTimeline(groupId, { maxId }));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchGroup(groupId));
|
||||||
|
dispatch(expandGroupTimeline(groupId));
|
||||||
|
|
||||||
|
dispatch(groupCompose(`group:${groupId}`, groupId));
|
||||||
|
|
||||||
|
const disconnect = dispatch(connectGroupStream(groupId));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disconnect();
|
||||||
|
};
|
||||||
|
}, [groupId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack space={2}>
|
||||||
|
{!!account && relationship?.member && (
|
||||||
|
<div className='px-2 py-4 border-b border-solid border-gray-200 dark:border-gray-800'>
|
||||||
|
<HStack alignItems='start' space={4}>
|
||||||
|
<Link to={`/@${account.acct}`}>
|
||||||
|
<Avatar src={account.avatar} size={46} />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<ComposeForm
|
||||||
|
id={`group:${groupId}`}
|
||||||
|
shouldCondense
|
||||||
|
autoFocus={false}
|
||||||
|
group={groupId}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Timeline
|
||||||
|
scrollKey='group_timeline'
|
||||||
|
timelineId={`group:${groupId}`}
|
||||||
|
onLoadMore={handleLoadMore}
|
||||||
|
emptyMessage={<FormattedMessage id='empty_column.group' defaultMessage='There are no posts in this group yet.' />}
|
||||||
|
divideType='border'
|
||||||
|
showGroup={false}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupTimeline;
|
|
@ -0,0 +1,96 @@
|
||||||
|
import React, { useCallback, useEffect } from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { deleteGroup, editGroup, fetchGroup } from 'soapbox/actions/groups';
|
||||||
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
|
import List, { ListItem } from 'soapbox/components/list';
|
||||||
|
import { CardBody, Column, Spinner } from 'soapbox/components/ui';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
import { makeGetGroup } from 'soapbox/selectors';
|
||||||
|
|
||||||
|
import ColumnForbidden from '../ui/components/column-forbidden';
|
||||||
|
|
||||||
|
type RouteParams = { id: string };
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
heading: { id: 'column.manage_group', defaultMessage: 'Manage group' },
|
||||||
|
editGroup: { id: 'manage_group.edit_group', defaultMessage: 'Edit group' },
|
||||||
|
pendingRequests: { id: 'manage_group.pending_requests', defaultMessage: 'Pending requests' },
|
||||||
|
blockedMembers: { id: 'manage_group.blocked_members', defaultMessage: 'Blocked members' },
|
||||||
|
deleteGroup: { id: 'manage_group.delete_group', defaultMessage: 'Delete group' },
|
||||||
|
deleteConfirm: { id: 'confirmations.delete_group.confirm', defaultMessage: 'Delete' },
|
||||||
|
deleteHeading: { id: 'confirmations.delete_group.heading', defaultMessage: 'Delete group' },
|
||||||
|
deleteMessage: { id: 'confirmations.delete_group.message', defaultMessage: 'Are you sure you want to delete this group? This is a permanent action that cannot be undone.' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IManageGroup {
|
||||||
|
params: RouteParams
|
||||||
|
}
|
||||||
|
|
||||||
|
const ManageGroup: React.FC<IManageGroup> = ({ params }) => {
|
||||||
|
const history = useHistory();
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const id = params?.id || '';
|
||||||
|
|
||||||
|
const getGroup = useCallback(makeGetGroup(), []);
|
||||||
|
const group = useAppSelector(state => getGroup(state, id));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!group) dispatch(fetchGroup(id));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (!group || !group.relationship) {
|
||||||
|
return (
|
||||||
|
<Column label={intl.formatMessage(messages.heading)}>
|
||||||
|
<Spinner />
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!group.relationship.role || !['admin', 'moderator'].includes(group.relationship.role)) {
|
||||||
|
return (<ColumnForbidden />);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEditGroup = () =>
|
||||||
|
dispatch(editGroup(group));
|
||||||
|
|
||||||
|
const onDeleteGroup = () =>
|
||||||
|
dispatch(openModal('CONFIRM', {
|
||||||
|
icon: require('@tabler/icons/trash.svg'),
|
||||||
|
heading: intl.formatMessage(messages.deleteHeading),
|
||||||
|
message: intl.formatMessage(messages.deleteMessage),
|
||||||
|
confirm: intl.formatMessage(messages.deleteConfirm),
|
||||||
|
onConfirm: () => dispatch(deleteGroup(id)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const navigateToPending = () => history.push(`/groups/${id}/manage/requests`);
|
||||||
|
const navigateToBlocks = () => history.push(`/groups/${id}/manage/blocks`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column label={intl.formatMessage(messages.heading)} backHref={`/groups/${id}`}>
|
||||||
|
<CardBody className='space-y-4'>
|
||||||
|
{group.relationship.role === 'admin' && (
|
||||||
|
<List>
|
||||||
|
<ListItem label={intl.formatMessage(messages.editGroup)} onClick={onEditGroup}>
|
||||||
|
<span dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
<List>
|
||||||
|
<ListItem label={intl.formatMessage(messages.pendingRequests)} onClick={navigateToPending} />
|
||||||
|
<ListItem label={intl.formatMessage(messages.blockedMembers)} onClick={navigateToBlocks} />
|
||||||
|
</List>
|
||||||
|
{group.relationship.role === 'admin' && (
|
||||||
|
<List>
|
||||||
|
<ListItem label={intl.formatMessage(messages.deleteGroup)} onClick={onDeleteGroup} />
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ManageGroup;
|
|
@ -0,0 +1,106 @@
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
|
||||||
|
import { fetchGroups } from 'soapbox/actions/groups';
|
||||||
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
|
import GroupCard from 'soapbox/components/group-card';
|
||||||
|
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||||
|
import { Button, Column, Spinner, Stack, Text } from 'soapbox/components/ui';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
import { PERMISSION_CREATE_GROUPS, hasPermission } from 'soapbox/utils/permissions';
|
||||||
|
|
||||||
|
import PlaceholderGroupCard from '../placeholder/components/placeholder-group-card';
|
||||||
|
|
||||||
|
import type { List as ImmutableList } from 'immutable';
|
||||||
|
import type { RootState } from 'soapbox/store';
|
||||||
|
import type { Group as GroupEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
const getOrderedGroups = createSelector([
|
||||||
|
(state: RootState) => state.groups.items,
|
||||||
|
(state: RootState) => state.groups.isLoading,
|
||||||
|
(state: RootState) => state.group_relationships,
|
||||||
|
], (groups, isLoading, group_relationships) => ({
|
||||||
|
groups: (groups.toList().filter((item: GroupEntity | false) => !!item) as ImmutableList<GroupEntity>)
|
||||||
|
.map((item) => item.set('relationship', group_relationships.get(item.id) || null))
|
||||||
|
.filter((item) => item.relationship?.member)
|
||||||
|
.sort((a, b) => a.display_name.localeCompare(b.display_name)),
|
||||||
|
isLoading,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const Groups: React.FC = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const { groups, isLoading } = useAppSelector((state) => getOrderedGroups(state));
|
||||||
|
const canCreateGroup = useAppSelector((state) => hasPermission(state, PERMISSION_CREATE_GROUPS));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchGroups());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const createGroup = () => {
|
||||||
|
dispatch(openModal('MANAGE_GROUP'));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!groups) {
|
||||||
|
return (
|
||||||
|
<Column>
|
||||||
|
<Spinner />
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyMessage = (
|
||||||
|
<Stack space={6} alignItems='center' justifyContent='center' className='p-6 h-full'>
|
||||||
|
<Stack space={2} className='max-w-sm'>
|
||||||
|
<Text size='2xl' weight='bold' tag='h2' align='center'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='groups.empty.title'
|
||||||
|
defaultMessage='No Groups yet'
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text size='sm' theme='muted' align='center'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='groups.empty.subtitle'
|
||||||
|
defaultMessage='Start discovering groups to join or create your own.'
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack className='gap-4'>
|
||||||
|
{canCreateGroup && (
|
||||||
|
<Button
|
||||||
|
className='sm:w-fit sm:self-end xl:hidden'
|
||||||
|
icon={require('@tabler/icons/circles.svg')}
|
||||||
|
onClick={createGroup}
|
||||||
|
theme='secondary'
|
||||||
|
block
|
||||||
|
>
|
||||||
|
<FormattedMessage id='new_group_panel.action' defaultMessage='Create group' />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<ScrollableList
|
||||||
|
scrollKey='groups'
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
|
itemClassName='py-3 first:pt-0 last:pb-0'
|
||||||
|
isLoading={isLoading}
|
||||||
|
showLoading={isLoading && !groups.count()}
|
||||||
|
placeholderComponent={PlaceholderGroupCard}
|
||||||
|
placeholderCount={3}
|
||||||
|
>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<Link key={group.id} to={`/groups/${group.id}`}>
|
||||||
|
<GroupCard group={group as GroupEntity} />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</ScrollableList>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Groups;
|
|
@ -8,9 +8,9 @@ import { reblog, favourite, unreblog, unfavourite } from 'soapbox/actions/intera
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
import { getSettings } from 'soapbox/actions/settings';
|
import { getSettings } from 'soapbox/actions/settings';
|
||||||
import { hideStatus, revealStatus } from 'soapbox/actions/statuses';
|
import { hideStatus, revealStatus } from 'soapbox/actions/statuses';
|
||||||
|
import Account from 'soapbox/components/account';
|
||||||
import Icon from 'soapbox/components/icon';
|
import Icon from 'soapbox/components/icon';
|
||||||
import { HStack, Text, Emoji } from 'soapbox/components/ui';
|
import { HStack, Text, Emoji } from 'soapbox/components/ui';
|
||||||
import AccountContainer from 'soapbox/containers/account-container';
|
|
||||||
import StatusContainer from 'soapbox/containers/status-container';
|
import StatusContainer from 'soapbox/containers/status-container';
|
||||||
import { useAppDispatch, useAppSelector, useInstance } from 'soapbox/hooks';
|
import { useAppDispatch, useAppSelector, useInstance } from 'soapbox/hooks';
|
||||||
import { makeGetNotification } from 'soapbox/selectors';
|
import { makeGetNotification } from 'soapbox/selectors';
|
||||||
|
@ -289,16 +289,16 @@ const Notification: React.FC<INotificaton> = (props) => {
|
||||||
case 'follow':
|
case 'follow':
|
||||||
case 'user_approved':
|
case 'user_approved':
|
||||||
return account && typeof account === 'object' ? (
|
return account && typeof account === 'object' ? (
|
||||||
<AccountContainer
|
<Account
|
||||||
id={account.id}
|
account={account}
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
avatarSize={avatarSize}
|
avatarSize={avatarSize}
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
case 'follow_request':
|
case 'follow_request':
|
||||||
return account && typeof account === 'object' ? (
|
return account && typeof account === 'object' ? (
|
||||||
<AccountContainer
|
<Account
|
||||||
id={account.id}
|
account={account}
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
avatarSize={avatarSize}
|
avatarSize={avatarSize}
|
||||||
actionType='follow_request'
|
actionType='follow_request'
|
||||||
|
@ -306,8 +306,8 @@ const Notification: React.FC<INotificaton> = (props) => {
|
||||||
) : null;
|
) : null;
|
||||||
case 'move':
|
case 'move':
|
||||||
return account && typeof account === 'object' && notification.target && typeof notification.target === 'object' ? (
|
return account && typeof account === 'object' && notification.target && typeof notification.target === 'object' ? (
|
||||||
<AccountContainer
|
<Account
|
||||||
id={notification.target.id}
|
account={notification.target}
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
avatarSize={avatarSize}
|
avatarSize={avatarSize}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { HStack, Stack, Text } from 'soapbox/components/ui';
|
||||||
|
|
||||||
|
import { generateText, randomIntFromInterval } from '../utils';
|
||||||
|
|
||||||
|
const PlaceholderGroupCard = () => {
|
||||||
|
const groupNameLength = randomIntFromInterval(5, 25);
|
||||||
|
const roleLength = randomIntFromInterval(5, 15);
|
||||||
|
const privacyLength = randomIntFromInterval(5, 15);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='overflow-hidden animate-pulse'>
|
||||||
|
<Stack className='bg-white dark:bg-primary-900 border border-solid border-gray-300 dark:border-primary-800 rounded-lg sm:rounded-xl'>
|
||||||
|
<div className='bg-primary-100 dark:bg-gray-800 h-[120px] relative -m-[1px] mb-0 rounded-t-lg sm:rounded-t-xl'>
|
||||||
|
<div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
|
||||||
|
<div className='h-16 w-16 rounded-full bg-primary-500 ring-2 ring-white dark:ring-primary-900' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Stack className='p-3 pt-9' alignItems='center' space={3}>
|
||||||
|
<Text size='lg' weight='bold'>{generateText(groupNameLength)}</Text>
|
||||||
|
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
|
||||||
|
<span>{generateText(roleLength)}</span>
|
||||||
|
<span>{generateText(privacyLength)}</span>
|
||||||
|
</HStack>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlaceholderGroupCard;
|
|
@ -90,6 +90,7 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
|
||||||
timestamp={actualStatus.created_at}
|
timestamp={actualStatus.created_at}
|
||||||
avatarSize={42}
|
avatarSize={42}
|
||||||
hideActions
|
hideActions
|
||||||
|
approvalStatus={actualStatus.approval_status}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import { useDispatch } from 'react-redux';
|
|
||||||
|
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
import { Button } from 'soapbox/components/ui';
|
import { Button } from 'soapbox/components/ui';
|
||||||
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
const ComposeButton = () => {
|
const ComposeButton = () => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const onOpenCompose = () => dispatch(openModal('COMPOSE'));
|
const onOpenCompose = () => dispatch(openModal('COMPOSE'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { List as ImmutableList } from 'immutable';
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
|
import { expandGroupMediaTimeline } from 'soapbox/actions/timelines';
|
||||||
|
import { Spinner, Text, Widget } from 'soapbox/components/ui';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
import { getGroupGallery } from 'soapbox/selectors';
|
||||||
|
|
||||||
|
import MediaItem from '../../account-gallery/components/media-item';
|
||||||
|
|
||||||
|
import type { Attachment, Group } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
interface IGroupMediaPanel {
|
||||||
|
group?: Group,
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupMediaPanel: React.FC<IGroupMediaPanel> = ({ group }) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const attachments: ImmutableList<Attachment> = useAppSelector((state) => group ? getGroupGallery(state, group?.id) : ImmutableList());
|
||||||
|
|
||||||
|
const handleOpenMedia = (attachment: Attachment): void => {
|
||||||
|
if (attachment.type === 'video') {
|
||||||
|
dispatch(openModal('VIDEO', { media: attachment, status: attachment.status }));
|
||||||
|
} else {
|
||||||
|
const media = attachment.getIn(['status', 'media_attachments']) as ImmutableList<Attachment>;
|
||||||
|
const index = media.findIndex(x => x.id === attachment.id);
|
||||||
|
|
||||||
|
dispatch(openModal('MEDIA', { media, index, status: attachment.status, account: attachment.account }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
if (group) {
|
||||||
|
dispatch(expandGroupMediaTimeline(group.id))
|
||||||
|
// @ts-ignore
|
||||||
|
.then(() => setLoading(false))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}, [group?.id]);
|
||||||
|
|
||||||
|
const renderAttachments = () => {
|
||||||
|
const nineAttachments = attachments.slice(0, 9);
|
||||||
|
|
||||||
|
if (!nineAttachments.isEmpty()) {
|
||||||
|
return (
|
||||||
|
<div className='flex flex-wrap -m-1'>
|
||||||
|
{nineAttachments.map((attachment, _index) => (
|
||||||
|
<MediaItem
|
||||||
|
key={`${attachment.getIn(['status', 'id'])}+${attachment.id}`}
|
||||||
|
attachment={attachment}
|
||||||
|
displayWidth={255}
|
||||||
|
onOpenMedia={handleOpenMedia}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Text size='sm' theme='muted'>
|
||||||
|
<FormattedMessage id='media_panel.empty_message' defaultMessage='No media found.' />
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Widget title={<FormattedMessage id='media_panel.title' defaultMessage='Media' />}>
|
||||||
|
{group && (
|
||||||
|
<div className='w-full'>
|
||||||
|
{loading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
renderAttachments()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupMediaPanel;
|
|
@ -36,6 +36,7 @@ import {
|
||||||
EventMapModal,
|
EventMapModal,
|
||||||
EventParticipantsModal,
|
EventParticipantsModal,
|
||||||
PolicyModal,
|
PolicyModal,
|
||||||
|
ManageGroupModal,
|
||||||
} from 'soapbox/features/ui/util/async-components';
|
} from 'soapbox/features/ui/util/async-components';
|
||||||
|
|
||||||
import BundleContainer from '../containers/bundle-container';
|
import BundleContainer from '../containers/bundle-container';
|
||||||
|
@ -79,6 +80,7 @@ const MODAL_COMPONENTS = {
|
||||||
'EVENT_MAP': EventMapModal,
|
'EVENT_MAP': EventMapModal,
|
||||||
'EVENT_PARTICIPANTS': EventParticipantsModal,
|
'EVENT_PARTICIPANTS': EventParticipantsModal,
|
||||||
'POLICY': PolicyModal,
|
'POLICY': PolicyModal,
|
||||||
|
'MANAGE_GROUP': ManageGroupModal,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ModalType = keyof typeof MODAL_COMPONENTS | null;
|
export type ModalType = keyof typeof MODAL_COMPONENTS | null;
|
||||||
|
|
|
@ -214,7 +214,7 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
|
||||||
<FormGroup
|
<FormGroup
|
||||||
labelText={<FormattedMessage id='compose_event.fields.banner_label' defaultMessage='Event banner' />}
|
labelText={<FormattedMessage id='compose_event.fields.banner_label' defaultMessage='Event banner' />}
|
||||||
>
|
>
|
||||||
<div className='flex items-center justify-center bg-gray-200 dark:bg-gray-900/50 rounded-lg text-black dark:text-white sm:shadow dark:sm:shadow-inset overflow-hidden h-24 sm:h-32 relative'>
|
<div className='flex items-center justify-center bg-primary-100 dark:bg-gray-800 rounded-lg text-primary-500 dark:text-white sm:shadow dark:sm:shadow-inset overflow-hidden h-24 sm:h-32 relative'>
|
||||||
{banner ? (
|
{banner ? (
|
||||||
<>
|
<>
|
||||||
<img className='h-full w-full object-cover' src={banner.url} alt='' />
|
<img className='h-full w-full object-cover' src={banner.url} alt='' />
|
||||||
|
@ -223,7 +223,6 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
|
||||||
) : (
|
) : (
|
||||||
<UploadButton disabled={isUploading} onSelectFile={handleFiles} />
|
<UploadButton disabled={isUploading} onSelectFile={handleFiles} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<FormGroup
|
<FormGroup
|
||||||
|
|
|
@ -1,23 +1,18 @@
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { IconButton } from 'soapbox/components/ui';
|
import Icon from 'soapbox/components/icon';
|
||||||
|
import { HStack, Text } from 'soapbox/components/ui';
|
||||||
import { useAppSelector } from 'soapbox/hooks';
|
import { useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
import type { List as ImmutableList } from 'immutable';
|
import type { List as ImmutableList } from 'immutable';
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
upload: { id: 'compose_event.upload_banner', defaultMessage: 'Upload event banner' },
|
|
||||||
});
|
|
||||||
|
|
||||||
interface IUploadButton {
|
interface IUploadButton {
|
||||||
disabled?: boolean,
|
disabled?: boolean,
|
||||||
onSelectFile: (files: FileList) => void,
|
onSelectFile: (files: FileList) => void,
|
||||||
}
|
}
|
||||||
|
|
||||||
const UploadButton: React.FC<IUploadButton> = ({ disabled, onSelectFile }) => {
|
const UploadButton: React.FC<IUploadButton> = ({ disabled, onSelectFile }) => {
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
const fileElement = useRef<HTMLInputElement>(null);
|
const fileElement = useRef<HTMLInputElement>(null);
|
||||||
const attachmentTypes = useAppSelector(state => state.instance.configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>)?.filter(type => type.startsWith('image/'));
|
const attachmentTypes = useAppSelector(state => state.instance.configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>)?.filter(type => type.startsWith('image/'));
|
||||||
|
|
||||||
|
@ -32,27 +27,25 @@ const UploadButton: React.FC<IUploadButton> = ({ disabled, onSelectFile }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<HStack className='h-full w-full text-primary-500 dark:text-accent-blue cursor-pointer' space={3} alignItems='center' justifyContent='center' element='label'>
|
||||||
<IconButton
|
<Icon
|
||||||
src={require('@tabler/icons/photo-plus.svg')}
|
src={require('@tabler/icons/photo-plus.svg')}
|
||||||
className='h-8 w-8 text-gray-600 hover:text-gray-700 dark:hover:text-gray-500'
|
className='h-7 w-7'
|
||||||
title={intl.formatMessage(messages.upload)}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label>
|
<Text size='sm' theme='primary' weight='semibold' transform='uppercase'>
|
||||||
<span className='sr-only'>{intl.formatMessage(messages.upload)}</span>
|
<FormattedMessage id='compose_event.upload_banner' defaultMessage='Upload photo' />
|
||||||
<input
|
</Text>
|
||||||
ref={fileElement}
|
<input
|
||||||
type='file'
|
ref={fileElement}
|
||||||
accept={attachmentTypes && attachmentTypes.toArray().join(',')}
|
type='file'
|
||||||
onChange={handleChange}
|
accept={attachmentTypes && attachmentTypes.toArray().join(',')}
|
||||||
disabled={disabled}
|
onChange={handleChange}
|
||||||
className='hidden'
|
disabled={disabled}
|
||||||
/>
|
className='hidden'
|
||||||
</label>
|
/>
|
||||||
</div>
|
</HStack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,92 @@
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { submitGroupEditor } from 'soapbox/actions/groups';
|
||||||
|
import { Modal, Stack } from 'soapbox/components/ui';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import DetailsStep from './steps/details-step';
|
||||||
|
import PrivacyStep from './steps/privacy-step';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
next: { id: 'manage_group.next', defaultMessage: 'Next' },
|
||||||
|
create: { id: 'manage_group.create', defaultMessage: 'Create' },
|
||||||
|
update: { id: 'manage_group.update', defaultMessage: 'Update' },
|
||||||
|
});
|
||||||
|
|
||||||
|
enum Steps {
|
||||||
|
ONE = 'ONE',
|
||||||
|
TWO = 'TWO',
|
||||||
|
}
|
||||||
|
|
||||||
|
const manageGroupSteps = {
|
||||||
|
ONE: PrivacyStep,
|
||||||
|
TWO: DetailsStep,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IManageGroupModal {
|
||||||
|
onClose: (type?: string) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ManageGroupModal: React.FC<IManageGroupModal> = ({ onClose }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const id = useAppSelector((state) => state.group_editor.groupId);
|
||||||
|
|
||||||
|
const isSubmitting = useAppSelector((state) => state.group_editor.isSubmitting);
|
||||||
|
|
||||||
|
const [currentStep, setCurrentStep] = useState<Steps>(id ? Steps.TWO : Steps.ONE);
|
||||||
|
|
||||||
|
const onClickClose = () => {
|
||||||
|
onClose('manage_group');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
dispatch(submitGroupEditor(true));
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmationText = useMemo(() => {
|
||||||
|
switch (currentStep) {
|
||||||
|
case Steps.TWO:
|
||||||
|
return intl.formatMessage(id ? messages.update : messages.create);
|
||||||
|
default:
|
||||||
|
return intl.formatMessage(messages.next);
|
||||||
|
}
|
||||||
|
}, [currentStep]);
|
||||||
|
|
||||||
|
const handleNextStep = () => {
|
||||||
|
switch (currentStep) {
|
||||||
|
case Steps.ONE:
|
||||||
|
setCurrentStep(Steps.TWO);
|
||||||
|
break;
|
||||||
|
case Steps.TWO:
|
||||||
|
handleSubmit();
|
||||||
|
onClose();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const StepToRender = manageGroupSteps[currentStep];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={id
|
||||||
|
? <FormattedMessage id='navigation_bar.edit_group' defaultMessage='Edit Group' />
|
||||||
|
: <FormattedMessage id='navigation_bar.create_group' defaultMessage='Create Group' />}
|
||||||
|
confirmationAction={handleNextStep}
|
||||||
|
confirmationText={confirmationText}
|
||||||
|
confirmationDisabled={isSubmitting}
|
||||||
|
confirmationFullWidth
|
||||||
|
onClose={onClickClose}
|
||||||
|
>
|
||||||
|
<Stack space={2}>
|
||||||
|
<StepToRender />
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ManageGroupModal;
|
|
@ -0,0 +1,180 @@
|
||||||
|
import classNames from 'clsx';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import {
|
||||||
|
changeGroupEditorTitle,
|
||||||
|
changeGroupEditorDescription,
|
||||||
|
changeGroupEditorMedia,
|
||||||
|
} from 'soapbox/actions/groups';
|
||||||
|
import Icon from 'soapbox/components/icon';
|
||||||
|
import { Avatar, Form, FormGroup, HStack, Input, Text, Textarea } from 'soapbox/components/ui';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
import { isDefaultAvatar, isDefaultHeader } from 'soapbox/utils/accounts';
|
||||||
|
import resizeImage from 'soapbox/utils/resize-image';
|
||||||
|
|
||||||
|
import type { List as ImmutableList } from 'immutable';
|
||||||
|
|
||||||
|
interface IMediaInput {
|
||||||
|
src: string | null,
|
||||||
|
accept: string,
|
||||||
|
onChange: React.ChangeEventHandler<HTMLInputElement>
|
||||||
|
disabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
groupNamePlaceholder: { id: 'manage_group.fields.name_placeholder', defaultMessage: 'Group Name' },
|
||||||
|
groupDescriptionPlaceholder: { id: 'manage_group.fields.description_placeholder', defaultMessage: 'Description' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const HeaderPicker: React.FC<IMediaInput> = ({ src, onChange, accept, disabled }) => {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className='h-24 sm:h-36 w-full text-primary-500 dark:text-accent-blue bg-primary-100 dark:bg-gray-800 cursor-pointer relative rounded-lg sm:shadow dark:sm:shadow-inset overflow-hidden'
|
||||||
|
>
|
||||||
|
{src && <img className='h-full w-full object-cover' src={src} alt='' />}
|
||||||
|
<HStack
|
||||||
|
className={classNames('h-full w-full top-0 absolute transition-opacity', {
|
||||||
|
'opacity-0 hover:opacity-90 bg-primary-100 dark:bg-gray-800': src,
|
||||||
|
})}
|
||||||
|
space={3}
|
||||||
|
alignItems='center'
|
||||||
|
justifyContent='center'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
src={require('@tabler/icons/photo-plus.svg')}
|
||||||
|
className='h-7 w-7'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text size='sm' theme='primary' weight='semibold' transform='uppercase'>
|
||||||
|
<FormattedMessage id='compose_event.upload_banner' defaultMessage='Upload photo' />
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<input
|
||||||
|
name='header'
|
||||||
|
type='file'
|
||||||
|
accept={accept}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
className='hidden'
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AvatarPicker: React.FC<IMediaInput> = ({ src, onChange, accept, disabled }) => {
|
||||||
|
return (
|
||||||
|
<label className='h-[72px] w-[72px] bg-primary-500 cursor-pointer rounded-full absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2 ring-2 ring-white dark:ring-primary-900'>
|
||||||
|
{src && <Avatar src={src} size={72} />}
|
||||||
|
<HStack
|
||||||
|
alignItems='center'
|
||||||
|
justifyContent='center'
|
||||||
|
|
||||||
|
className={classNames('h-full w-full left-0 top-0 rounded-full absolute transition-opacity', {
|
||||||
|
'opacity-0 hover:opacity-90 bg-primary-500': src,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
src={require('@tabler/icons/camera-plus.svg')}
|
||||||
|
className='h-7 w-7 text-white'
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
<span className='sr-only'>Upload avatar</span>
|
||||||
|
<input
|
||||||
|
name='avatar'
|
||||||
|
type='file'
|
||||||
|
accept={accept}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
className='hidden'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DetailsStep = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const groupId = useAppSelector((state) => state.group_editor.groupId);
|
||||||
|
const isUploading = useAppSelector((state) => state.group_editor.isUploading);
|
||||||
|
const name = useAppSelector((state) => state.group_editor.displayName);
|
||||||
|
const description = useAppSelector((state) => state.group_editor.note);
|
||||||
|
|
||||||
|
const [avatarSrc, setAvatarSrc] = useState<string | null>(null);
|
||||||
|
const [headerSrc, setHeaderSrc] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const attachmentTypes = useAppSelector(
|
||||||
|
state => state.instance.configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>,
|
||||||
|
)?.filter(type => type.startsWith('image/')).toArray().join(',');
|
||||||
|
|
||||||
|
const onChangeName: React.ChangeEventHandler<HTMLInputElement> = ({ target }) => {
|
||||||
|
dispatch(changeGroupEditorTitle(target.value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onChangeDescription: React.ChangeEventHandler<HTMLTextAreaElement> = ({ target }) => {
|
||||||
|
dispatch(changeGroupEditorDescription(target.value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = e => {
|
||||||
|
const rawFile = e.target.files?.item(0);
|
||||||
|
|
||||||
|
if (!rawFile) return;
|
||||||
|
|
||||||
|
if (e.target.name === 'avatar') {
|
||||||
|
resizeImage(rawFile, 400 * 400).then(file => {
|
||||||
|
dispatch(changeGroupEditorMedia('avatar', file));
|
||||||
|
setAvatarSrc(URL.createObjectURL(file));
|
||||||
|
}).catch(console.error);
|
||||||
|
} else {
|
||||||
|
resizeImage(rawFile, 1920 * 1080).then(file => {
|
||||||
|
dispatch(changeGroupEditorMedia('header', file));
|
||||||
|
setHeaderSrc(URL.createObjectURL(file));
|
||||||
|
}).catch(console.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!groupId) return;
|
||||||
|
|
||||||
|
dispatch((_, getState) => {
|
||||||
|
const group = getState().groups.items.get(groupId);
|
||||||
|
if (!group) return;
|
||||||
|
if (group.avatar && !isDefaultAvatar(group.avatar)) setAvatarSrc(group.avatar);
|
||||||
|
if (group.header && !isDefaultHeader(group.header)) setHeaderSrc(group.header);
|
||||||
|
});
|
||||||
|
}, [groupId]);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form>
|
||||||
|
<div className='flex mb-12 relative'>
|
||||||
|
<HeaderPicker src={headerSrc} accept={attachmentTypes} onChange={handleFileChange} disabled={isUploading} />
|
||||||
|
<AvatarPicker src={avatarSrc} accept={attachmentTypes} onChange={handleFileChange} disabled={isUploading} />
|
||||||
|
</div>
|
||||||
|
<FormGroup
|
||||||
|
labelText={<FormattedMessage id='manage_group.fields.name_label' defaultMessage='Group name (required)' />}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type='text'
|
||||||
|
placeholder={intl.formatMessage(messages.groupNamePlaceholder)}
|
||||||
|
value={name}
|
||||||
|
onChange={onChangeName}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup
|
||||||
|
labelText={<FormattedMessage id='manage_group.fields.description_label' defaultMessage='Description' />}
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
autoComplete='off'
|
||||||
|
placeholder={intl.formatMessage(messages.groupDescriptionPlaceholder)}
|
||||||
|
value={description}
|
||||||
|
onChange={onChangeDescription}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DetailsStep;
|
|
@ -0,0 +1,56 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { changeGroupEditorPrivacy } from 'soapbox/actions/groups';
|
||||||
|
import List, { ListItem } from 'soapbox/components/list';
|
||||||
|
import { Form, FormGroup, Stack, Text } from 'soapbox/components/ui';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
const PrivacyStep = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const locked = useAppSelector((state) => state.group_editor.locked);
|
||||||
|
|
||||||
|
const onChangePrivacy = (value: boolean) => {
|
||||||
|
dispatch(changeGroupEditorPrivacy(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack className='max-w-sm mx-auto' space={2}>
|
||||||
|
<Text size='3xl' weight='bold' align='center'>
|
||||||
|
<FormattedMessage id='manage_group.get_started' defaultMessage="Let's get started!" />
|
||||||
|
</Text>
|
||||||
|
<Text size='lg' theme='muted' align='center'>
|
||||||
|
<FormattedMessage id='manage_group.tagline' defaultMessage='Groups connect you with others based on shared interests.' />
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Form>
|
||||||
|
<FormGroup
|
||||||
|
labelText={<FormattedMessage id='manage_group.privacy.label' defaultMessage='Privacy settings' />}
|
||||||
|
>
|
||||||
|
<List>
|
||||||
|
<ListItem
|
||||||
|
label={<FormattedMessage id='manage_group.privacy.public.label' defaultMessage='Public' />}
|
||||||
|
hint={<FormattedMessage id='manage_group.privacy.public.hint' defaultMessage='Discoverable. Anyone can join.' />}
|
||||||
|
onSelect={() => onChangePrivacy(false)}
|
||||||
|
isSelected={!locked}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
label={<FormattedMessage id='manage_group.privacy.private.label' defaultMessage='Private (Owner approval required)' />}
|
||||||
|
hint={<FormattedMessage id='manage_group.privacy.private.hint' defaultMessage='Discoverable. Users can join after their request is approved.' />}
|
||||||
|
onSelect={() => onChangePrivacy(true)}
|
||||||
|
isSelected={locked}
|
||||||
|
/>
|
||||||
|
</List>
|
||||||
|
</FormGroup>
|
||||||
|
<Text size='sm' theme='muted' align='center'>
|
||||||
|
<FormattedMessage id='manage_group.privacy.hint' defaultMessage='These settings cannot be changed later.' />
|
||||||
|
</Text>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PrivacyStep;
|
|
@ -0,0 +1,44 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
|
import { Button, Stack, Text } from 'soapbox/components/ui';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
import { PERMISSION_CREATE_GROUPS, hasPermission } from 'soapbox/utils/permissions';
|
||||||
|
|
||||||
|
const NewGroupPanel = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const canCreateGroup = useAppSelector((state) => hasPermission(state, PERMISSION_CREATE_GROUPS));
|
||||||
|
|
||||||
|
const createGroup = () => {
|
||||||
|
dispatch(openModal('MANAGE_GROUP'));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!canCreateGroup) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack space={2}>
|
||||||
|
<Stack>
|
||||||
|
<Text size='lg' weight='bold'>
|
||||||
|
<FormattedMessage id='new_group_panel.title' defaultMessage='Create New Group' />
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text theme='muted' size='sm'>
|
||||||
|
<FormattedMessage id='new_group_panel.subtitle' defaultMessage="Can't find what you're looking for? Start your own private or public group." />
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
icon={require('@tabler/icons/circles.svg')}
|
||||||
|
onClick={createGroup}
|
||||||
|
theme='secondary'
|
||||||
|
block
|
||||||
|
>
|
||||||
|
<FormattedMessage id='new_group_panel.action' defaultMessage='Create group' />
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NewGroupPanel;
|
|
@ -51,7 +51,7 @@ const ProfileMediaPanel: React.FC<IProfileMediaPanel> = ({ account }) => {
|
||||||
|
|
||||||
if (!nineAttachments.isEmpty()) {
|
if (!nineAttachments.isEmpty()) {
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-wrap'>
|
<div className='flex flex-wrap -m-1'>
|
||||||
{nineAttachments.map((attachment, _index) => (
|
{nineAttachments.map((attachment, _index) => (
|
||||||
<MediaItem
|
<MediaItem
|
||||||
key={`${attachment.getIn(['status', 'id'])}+${attachment.id}`}
|
key={`${attachment.getIn(['status', 'id'])}+${attachment.id}`}
|
||||||
|
@ -74,7 +74,7 @@ const ProfileMediaPanel: React.FC<IProfileMediaPanel> = ({ account }) => {
|
||||||
return (
|
return (
|
||||||
<Widget title={<FormattedMessage id='media_panel.title' defaultMessage='Media' />}>
|
<Widget title={<FormattedMessage id='media_panel.title' defaultMessage='Media' />}>
|
||||||
{account && (
|
{account && (
|
||||||
<div className='w-full py-2'>
|
<div className='w-full'>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
) : (
|
) : (
|
||||||
|
|
|
@ -39,7 +39,7 @@ const WhoToFollowPanel = ({ limit }: IWhoToFollowPanel) => {
|
||||||
action={
|
action={
|
||||||
<Link className='text-right' to='/suggestions'>
|
<Link className='text-right' to='/suggestions'>
|
||||||
<Text tag='span' theme='primary' size='sm' className='hover:underline'>
|
<Text tag='span' theme='primary' size='sm' className='hover:underline'>
|
||||||
<FormattedMessage id='feed_suggestions.view_all' defaultMessage='View all' />
|
<FormattedMessage id='feed_suggestions.view_all' defaultMessage='View all' />
|
||||||
</Text>
|
</Text>
|
||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,6 +29,8 @@ import AdminPage from 'soapbox/pages/admin-page';
|
||||||
import ChatsPage from 'soapbox/pages/chats-page';
|
import ChatsPage from 'soapbox/pages/chats-page';
|
||||||
import DefaultPage from 'soapbox/pages/default-page';
|
import DefaultPage from 'soapbox/pages/default-page';
|
||||||
import EventPage from 'soapbox/pages/event-page';
|
import EventPage from 'soapbox/pages/event-page';
|
||||||
|
import GroupPage from 'soapbox/pages/group-page';
|
||||||
|
import GroupsPage from 'soapbox/pages/groups-page';
|
||||||
import HomePage from 'soapbox/pages/home-page';
|
import HomePage from 'soapbox/pages/home-page';
|
||||||
import ProfilePage from 'soapbox/pages/profile-page';
|
import ProfilePage from 'soapbox/pages/profile-page';
|
||||||
import RemoteInstancePage from 'soapbox/pages/remote-instance-page';
|
import RemoteInstancePage from 'soapbox/pages/remote-instance-page';
|
||||||
|
@ -112,6 +114,12 @@ import {
|
||||||
EventInformation,
|
EventInformation,
|
||||||
EventDiscussion,
|
EventDiscussion,
|
||||||
Events,
|
Events,
|
||||||
|
Groups,
|
||||||
|
GroupMembers,
|
||||||
|
GroupTimeline,
|
||||||
|
ManageGroup,
|
||||||
|
GroupBlockedMembers,
|
||||||
|
GroupMembershipRequests,
|
||||||
} from './util/async-components';
|
} from './util/async-components';
|
||||||
import { WrappedRoute } from './util/react-router-helpers';
|
import { WrappedRoute } from './util/react-router-helpers';
|
||||||
|
|
||||||
|
@ -272,6 +280,13 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
|
||||||
{features.events && <WrappedRoute path='/@:username/events/:statusId/discussion' publicRoute exact page={EventPage} component={EventDiscussion} content={children} />}
|
{features.events && <WrappedRoute path='/@:username/events/:statusId/discussion' publicRoute exact page={EventPage} component={EventDiscussion} content={children} />}
|
||||||
<Redirect from='/@:username/:statusId' to='/@:username/posts/:statusId' />
|
<Redirect from='/@:username/:statusId' to='/@:username/posts/:statusId' />
|
||||||
|
|
||||||
|
{features.groups && <WrappedRoute path='/groups' exact page={GroupsPage} component={Groups} content={children} />}
|
||||||
|
{features.groups && <WrappedRoute path='/groups/:id' exact page={GroupPage} component={GroupTimeline} content={children} />}
|
||||||
|
{features.groups && <WrappedRoute path='/groups/:id/members' exact page={GroupPage} component={GroupMembers} content={children} />}
|
||||||
|
{features.groups && <WrappedRoute path='/groups/:id/manage' exact page={DefaultPage} component={ManageGroup} content={children} />}
|
||||||
|
{features.groups && <WrappedRoute path='/groups/:id/manage/blocks' exact page={DefaultPage} component={GroupBlockedMembers} content={children} />}
|
||||||
|
{features.groups && <WrappedRoute path='/groups/:id/manage/requests' exact page={DefaultPage} component={GroupMembershipRequests} content={children} />}
|
||||||
|
|
||||||
<WrappedRoute path='/statuses/new' page={DefaultPage} component={NewStatus} content={children} exact />
|
<WrappedRoute path='/statuses/new' page={DefaultPage} component={NewStatus} content={children} exact />
|
||||||
<WrappedRoute path='/statuses/:statusId' exact page={StatusPage} component={Status} content={children} />
|
<WrappedRoute path='/statuses/:statusId' exact page={StatusPage} component={Status} content={children} />
|
||||||
{features.scheduledStatuses && <WrappedRoute path='/scheduled_statuses' page={DefaultPage} component={ScheduledStatuses} content={children} />}
|
{features.scheduledStatuses && <WrappedRoute path='/scheduled_statuses' page={DefaultPage} component={ScheduledStatuses} content={children} />}
|
||||||
|
|
|
@ -541,3 +541,39 @@ export function EventParticipantsModal() {
|
||||||
export function Events() {
|
export function Events() {
|
||||||
return import(/* webpackChunkName: "features/events" */'../../events');
|
return import(/* webpackChunkName: "features/events" */'../../events');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function Groups() {
|
||||||
|
return import(/* webpackChunkName: "features/groups" */'../../groups');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupMembers() {
|
||||||
|
return import(/* webpackChunkName: "features/groups" */'../../group/group-members');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupTimeline() {
|
||||||
|
return import(/* webpackChunkName: "features/groups" */'../../group/group-timeline');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ManageGroup() {
|
||||||
|
return import(/* webpackChunkName: "features/groups" */'../../group/manage-group');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupBlockedMembers() {
|
||||||
|
return import(/* webpackChunkName: "features/groups" */'../../group/group-blocked-members');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupMembershipRequests() {
|
||||||
|
return import(/* webpackChunkName: "features/groups" */'../../group/group-membership-requests');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ManageGroupModal() {
|
||||||
|
return import(/* webpackChunkName: "features/manage_group_modal" */'../components/modals/manage-group-modal/manage-group-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewGroupPanel() {
|
||||||
|
return import(/* webpackChunkName: "features/groups" */'../components/panels/new-group-panel');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupMediaPanel() {
|
||||||
|
return import(/* webpackChunkName: "features/groups" */'../components/group-media-panel');
|
||||||
|
}
|
||||||
|
|
|
@ -319,10 +319,14 @@
|
||||||
"column.follow_requests": "Follow requests",
|
"column.follow_requests": "Follow requests",
|
||||||
"column.followers": "Followers",
|
"column.followers": "Followers",
|
||||||
"column.following": "Following",
|
"column.following": "Following",
|
||||||
|
"column.group_blocked_members": "Blocked members",
|
||||||
|
"column.group_pending_requests": "Pending requests",
|
||||||
|
"column.groups": "Groups",
|
||||||
"column.home": "Home",
|
"column.home": "Home",
|
||||||
"column.import_data": "Import data",
|
"column.import_data": "Import data",
|
||||||
"column.info": "Server information",
|
"column.info": "Server information",
|
||||||
"column.lists": "Lists",
|
"column.lists": "Lists",
|
||||||
|
"column.manage_group": "Manage group",
|
||||||
"column.mentions": "Mentions",
|
"column.mentions": "Mentions",
|
||||||
"column.mfa": "Multi-Factor Authentication",
|
"column.mfa": "Multi-Factor Authentication",
|
||||||
"column.mfa_cancel": "Cancel",
|
"column.mfa_cancel": "Cancel",
|
||||||
|
@ -431,6 +435,9 @@
|
||||||
"confirmations.block.confirm": "Block",
|
"confirmations.block.confirm": "Block",
|
||||||
"confirmations.block.heading": "Block @{name}",
|
"confirmations.block.heading": "Block @{name}",
|
||||||
"confirmations.block.message": "Are you sure you want to block {name}?",
|
"confirmations.block.message": "Are you sure you want to block {name}?",
|
||||||
|
"confirmations.block_from_group.confirm": "Block",
|
||||||
|
"confirmations.block_from_group.heading": "Block group member",
|
||||||
|
"confirmations.block_from_group.message": "Are you sure you want to block @{name} from interacting with this group?",
|
||||||
"confirmations.cancel.confirm": "Discard",
|
"confirmations.cancel.confirm": "Discard",
|
||||||
"confirmations.cancel.heading": "Discard post",
|
"confirmations.cancel.heading": "Discard post",
|
||||||
"confirmations.cancel.message": "Are you sure you want to cancel creating this post?",
|
"confirmations.cancel.message": "Are you sure you want to cancel creating this post?",
|
||||||
|
@ -445,17 +452,30 @@
|
||||||
"confirmations.delete_event.confirm": "Delete",
|
"confirmations.delete_event.confirm": "Delete",
|
||||||
"confirmations.delete_event.heading": "Delete event",
|
"confirmations.delete_event.heading": "Delete event",
|
||||||
"confirmations.delete_event.message": "Are you sure you want to delete this event?",
|
"confirmations.delete_event.message": "Are you sure you want to delete this event?",
|
||||||
|
"confirmations.delete_from_group.heading": "Delete from group",
|
||||||
|
"confirmations.delete_from_group.message": "Are you sure you want to delete @{name}'s post?",
|
||||||
|
"confirmations.delete_group.confirm": "Delete",
|
||||||
|
"confirmations.delete_group.heading": "Delete group",
|
||||||
|
"confirmations.delete_group.message": "Are you sure you want to delete this group? This is a permanent action that cannot be undone.",
|
||||||
"confirmations.delete_list.confirm": "Delete",
|
"confirmations.delete_list.confirm": "Delete",
|
||||||
"confirmations.delete_list.heading": "Delete list",
|
"confirmations.delete_list.heading": "Delete list",
|
||||||
"confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
|
"confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
|
||||||
"confirmations.domain_block.confirm": "Hide entire domain",
|
"confirmations.domain_block.confirm": "Hide entire domain",
|
||||||
"confirmations.domain_block.heading": "Block {domain}",
|
"confirmations.domain_block.heading": "Block {domain}",
|
||||||
"confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications.",
|
"confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications.",
|
||||||
|
"confirmations.kick_from_group.confirm": "Kick",
|
||||||
|
"confirmations.kick_from_group.heading": "Kick group member",
|
||||||
|
"confirmations.kick_from_group.message": "Are you sure you want to kick @{name} from this group?",
|
||||||
"confirmations.leave_event.confirm": "Leave event",
|
"confirmations.leave_event.confirm": "Leave event",
|
||||||
"confirmations.leave_event.message": "If you want to rejoin the event, the request will be manually reviewed again. Are you sure you want to proceed?",
|
"confirmations.leave_event.message": "If you want to rejoin the event, the request will be manually reviewed again. Are you sure you want to proceed?",
|
||||||
|
"confirmations.leave_group.confirm": "Leave",
|
||||||
|
"confirmations.leave_group.heading": "Leave group",
|
||||||
|
"confirmations.leave_group.message": "You are about to leave the group. Do you want to continue?",
|
||||||
"confirmations.mute.confirm": "Mute",
|
"confirmations.mute.confirm": "Mute",
|
||||||
"confirmations.mute.heading": "Mute @{name}",
|
"confirmations.mute.heading": "Mute @{name}",
|
||||||
"confirmations.mute.message": "Are you sure you want to mute {name}?",
|
"confirmations.mute.message": "Are you sure you want to mute {name}?",
|
||||||
|
"confirmations.promote_in_group.confirm": "Promote",
|
||||||
|
"confirmations.promote_in_group.message": "Are you sure you want to promote @{name}? You will not be able to demote them.",
|
||||||
"confirmations.redraft.confirm": "Delete & redraft",
|
"confirmations.redraft.confirm": "Delete & redraft",
|
||||||
"confirmations.redraft.heading": "Delete & redraft",
|
"confirmations.redraft.heading": "Delete & redraft",
|
||||||
"confirmations.redraft.message": "Are you sure you want to delete this post and re-draft it? Favorites and reposts will be lost, and replies to the original post will be orphaned.",
|
"confirmations.redraft.message": "Are you sure you want to delete this post and re-draft it? Favorites and reposts will be lost, and replies to the original post will be orphaned.",
|
||||||
|
@ -606,6 +626,9 @@
|
||||||
"empty_column.filters": "You haven't created any muted words yet.",
|
"empty_column.filters": "You haven't created any muted words yet.",
|
||||||
"empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.",
|
"empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.",
|
||||||
"empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.",
|
"empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.",
|
||||||
|
"empty_column.group": "There are no posts in this group yet.",
|
||||||
|
"empty_column.group_blocks": "The group hasn't blocked any users yet.",
|
||||||
|
"empty_column.group_membership_requests": "There are no pending membership requests for this group.",
|
||||||
"empty_column.hashtag": "There is nothing in this hashtag yet.",
|
"empty_column.hashtag": "There is nothing in this hashtag yet.",
|
||||||
"empty_column.home": "Or you can visit {public} to get started and meet other users.",
|
"empty_column.home": "Or you can visit {public} to get started and meet other users.",
|
||||||
"empty_column.home.local_tab": "the {site_title} tab",
|
"empty_column.home.local_tab": "the {site_title} tab",
|
||||||
|
@ -621,6 +644,7 @@
|
||||||
"empty_column.remote": "There is nothing here! Manually follow users from {instance} to fill it up.",
|
"empty_column.remote": "There is nothing here! Manually follow users from {instance} to fill it up.",
|
||||||
"empty_column.scheduled_statuses": "You don't have any scheduled statuses yet. When you add one, it will show up here.",
|
"empty_column.scheduled_statuses": "You don't have any scheduled statuses yet. When you add one, it will show up here.",
|
||||||
"empty_column.search.accounts": "There are no people results for \"{term}\"",
|
"empty_column.search.accounts": "There are no people results for \"{term}\"",
|
||||||
|
"empty_column.search.groups": "There are no groups results for \"{term}\"",
|
||||||
"empty_column.search.hashtags": "There are no hashtags results for \"{term}\"",
|
"empty_column.search.hashtags": "There are no hashtags results for \"{term}\"",
|
||||||
"empty_column.search.statuses": "There are no posts results for \"{term}\"",
|
"empty_column.search.statuses": "There are no posts results for \"{term}\"",
|
||||||
"empty_column.test": "The test timeline is empty.",
|
"empty_column.test": "The test timeline is empty.",
|
||||||
|
@ -696,6 +720,42 @@
|
||||||
"gdpr.message": "{siteTitle} uses session cookies, which are essential to the website's functioning.",
|
"gdpr.message": "{siteTitle} uses session cookies, which are essential to the website's functioning.",
|
||||||
"gdpr.title": "{siteTitle} uses cookies",
|
"gdpr.title": "{siteTitle} uses cookies",
|
||||||
"getting_started.open_source_notice": "{code_name} is open source software. You can contribute or report issues at {code_link} (v{code_version}).",
|
"getting_started.open_source_notice": "{code_name} is open source software. You can contribute or report issues at {code_link} (v{code_version}).",
|
||||||
|
"group.admin_subheading": "Group administrators",
|
||||||
|
"group.cancel_request": "Cancel request",
|
||||||
|
"group.group_mod_authorize": "Accept",
|
||||||
|
"group.group_mod_authorize.success": "Accepted @{name} to group",
|
||||||
|
"group.group_mod_block": "Block @{name} from group",
|
||||||
|
"group.group_mod_block.success": "Blocked @{name} from group",
|
||||||
|
"group.group_mod_demote": "Demote @{name}",
|
||||||
|
"group.group_mod_demote.success": "Demoted @{name} to group user",
|
||||||
|
"group.group_mod_kick": "Kick @{name} from group",
|
||||||
|
"group.group_mod_kick.success": "Kicked @{name} from group",
|
||||||
|
"group.group_mod_promote_admin": "Promote @{name} to group administrator",
|
||||||
|
"group.group_mod_promote_admin.success": "Promoted @{name} to group administrator",
|
||||||
|
"group.group_mod_promote_mod": "Promote @{name} to group moderator",
|
||||||
|
"group.group_mod_promote_mod.success": "Promoted @{name} to group moderator",
|
||||||
|
"group.group_mod_reject": "Reject",
|
||||||
|
"group.group_mod_reject.success": "Rejected @{name} from group",
|
||||||
|
"group.group_mod_unblock": "Unblock",
|
||||||
|
"group.group_mod_unblock.success": "Unblocked @{name} from group",
|
||||||
|
"group.header.alt": "Group header",
|
||||||
|
"group.join": "Join group",
|
||||||
|
"group.join.request_success": "Requested to join the group",
|
||||||
|
"group.join.success": "Joined the group",
|
||||||
|
"group.leave": "Leave group",
|
||||||
|
"group.leave.success": "Left the group",
|
||||||
|
"group.manage": "Manage group",
|
||||||
|
"group.moderator_subheading": "Group moderators",
|
||||||
|
"group.privacy.locked": "Private",
|
||||||
|
"group.privacy.public": "Public",
|
||||||
|
"group.request_join": "Request to join group",
|
||||||
|
"group.role.admin": "Admin",
|
||||||
|
"group.role.moderator": "Moderator",
|
||||||
|
"group.tabs.all": "All",
|
||||||
|
"group.tabs.members": "Members",
|
||||||
|
"group.user_subheading": "Users",
|
||||||
|
"groups.empty.subtitle": "Start discovering groups to join or create your own.",
|
||||||
|
"groups.empty.title": "No Groups yet",
|
||||||
"hashtag.column_header.tag_mode.all": "and {additional}",
|
"hashtag.column_header.tag_mode.all": "and {additional}",
|
||||||
"hashtag.column_header.tag_mode.any": "or {additional}",
|
"hashtag.column_header.tag_mode.any": "or {additional}",
|
||||||
"hashtag.column_header.tag_mode.none": "without {additional}",
|
"hashtag.column_header.tag_mode.none": "without {additional}",
|
||||||
|
@ -799,6 +859,27 @@
|
||||||
"login_external.errors.instance_fail": "The instance returned an error.",
|
"login_external.errors.instance_fail": "The instance returned an error.",
|
||||||
"login_external.errors.network_fail": "Connection failed. Is a browser extension blocking it?",
|
"login_external.errors.network_fail": "Connection failed. Is a browser extension blocking it?",
|
||||||
"login_form.header": "Sign In",
|
"login_form.header": "Sign In",
|
||||||
|
"manage_group.blocked_members": "Blocked members",
|
||||||
|
"manage_group.create": "Create",
|
||||||
|
"manage_group.delete_group": "Delete group",
|
||||||
|
"manage_group.edit_group": "Edit group",
|
||||||
|
"manage_group.edit_success": "The group was edited",
|
||||||
|
"manage_group.fields.description_label": "Description",
|
||||||
|
"manage_group.fields.description_placeholder": "Description",
|
||||||
|
"manage_group.fields.name_label": "Group name (required)",
|
||||||
|
"manage_group.fields.name_placeholder": "Group Name",
|
||||||
|
"manage_group.get_started": "Let's get started!",
|
||||||
|
"manage_group.next": "Next",
|
||||||
|
"manage_group.pending_requests": "Pending requests",
|
||||||
|
"manage_group.privacy.hint": "These settings cannot be changed later.",
|
||||||
|
"manage_group.privacy.label": "Privacy settings",
|
||||||
|
"manage_group.privacy.private.hint": "Discoverable. Users can join after their request is approved.",
|
||||||
|
"manage_group.privacy.private.label": "Private (Owner approval required)",
|
||||||
|
"manage_group.privacy.public.hint": "Discoverable. Anyone can join.",
|
||||||
|
"manage_group.privacy.public.label": "Public",
|
||||||
|
"manage_group.submit_success": "The group was created",
|
||||||
|
"manage_group.tagline": "Groups connect you with others based on shared interests.",
|
||||||
|
"manage_group.update": "Update",
|
||||||
"media_panel.empty_message": "No media found.",
|
"media_panel.empty_message": "No media found.",
|
||||||
"media_panel.title": "Media",
|
"media_panel.title": "Media",
|
||||||
"mfa.confirm.success_message": "MFA confirmed",
|
"mfa.confirm.success_message": "MFA confirmed",
|
||||||
|
@ -865,7 +946,9 @@
|
||||||
"navigation_bar.compose_quote": "Quote post",
|
"navigation_bar.compose_quote": "Quote post",
|
||||||
"navigation_bar.compose_reply": "Reply to post",
|
"navigation_bar.compose_reply": "Reply to post",
|
||||||
"navigation_bar.create_event": "Create new event",
|
"navigation_bar.create_event": "Create new event",
|
||||||
|
"navigation_bar.create_group": "Create Group",
|
||||||
"navigation_bar.domain_blocks": "Domain blocks",
|
"navigation_bar.domain_blocks": "Domain blocks",
|
||||||
|
"navigation_bar.edit_group": "Edit Group",
|
||||||
"navigation_bar.favourites": "Likes",
|
"navigation_bar.favourites": "Likes",
|
||||||
"navigation_bar.filters": "Filters",
|
"navigation_bar.filters": "Filters",
|
||||||
"navigation_bar.follow_requests": "Follow requests",
|
"navigation_bar.follow_requests": "Follow requests",
|
||||||
|
@ -877,6 +960,9 @@
|
||||||
"navigation_bar.preferences": "Preferences",
|
"navigation_bar.preferences": "Preferences",
|
||||||
"navigation_bar.profile_directory": "Profile directory",
|
"navigation_bar.profile_directory": "Profile directory",
|
||||||
"navigation_bar.soapbox_config": "Soapbox config",
|
"navigation_bar.soapbox_config": "Soapbox config",
|
||||||
|
"new_group_panel.action": "Create group",
|
||||||
|
"new_group_panel.subtitle": "Can't find what you're looking for? Start your own private or public group.",
|
||||||
|
"new_group_panel.title": "Create New Group",
|
||||||
"notification.favourite": "{name} liked your post",
|
"notification.favourite": "{name} liked your post",
|
||||||
"notification.follow": "{name} followed you",
|
"notification.follow": "{name} followed you",
|
||||||
"notification.follow_request": "{name} has requested to follow you",
|
"notification.follow_request": "{name} has requested to follow you",
|
||||||
|
@ -1107,6 +1193,7 @@
|
||||||
"search.placeholder": "Search",
|
"search.placeholder": "Search",
|
||||||
"search_results.accounts": "People",
|
"search_results.accounts": "People",
|
||||||
"search_results.filter_message": "You are searching for posts from @{acct}.",
|
"search_results.filter_message": "You are searching for posts from @{acct}.",
|
||||||
|
"search_results.groups": "Groups",
|
||||||
"search_results.hashtags": "Hashtags",
|
"search_results.hashtags": "Hashtags",
|
||||||
"search_results.statuses": "Posts",
|
"search_results.statuses": "Posts",
|
||||||
"security.codes.fail": "Failed to fetch backup codes",
|
"security.codes.fail": "Failed to fetch backup codes",
|
||||||
|
@ -1217,6 +1304,8 @@
|
||||||
"sponsored.subtitle": "Sponsored post",
|
"sponsored.subtitle": "Sponsored post",
|
||||||
"status.admin_account": "Moderate @{name}",
|
"status.admin_account": "Moderate @{name}",
|
||||||
"status.admin_status": "Open this post in the moderation interface",
|
"status.admin_status": "Open this post in the moderation interface",
|
||||||
|
"status.approval.pending": "Pending approval",
|
||||||
|
"status.approval.rejected": "Rejected",
|
||||||
"status.bookmark": "Bookmark",
|
"status.bookmark": "Bookmark",
|
||||||
"status.bookmarked": "Bookmark added.",
|
"status.bookmarked": "Bookmark added.",
|
||||||
"status.cancel_reblog_private": "Un-repost",
|
"status.cancel_reblog_private": "Un-repost",
|
||||||
|
@ -1226,11 +1315,16 @@
|
||||||
"status.delete": "Delete",
|
"status.delete": "Delete",
|
||||||
"status.detailed_status": "Detailed conversation view",
|
"status.detailed_status": "Detailed conversation view",
|
||||||
"status.direct": "Direct message @{name}",
|
"status.direct": "Direct message @{name}",
|
||||||
|
"status.disabled_replies.group_membership": "Only group members can reply",
|
||||||
"status.edit": "Edit",
|
"status.edit": "Edit",
|
||||||
"status.embed": "Embed post",
|
"status.embed": "Embed post",
|
||||||
"status.external": "View post on {domain}",
|
"status.external": "View post on {domain}",
|
||||||
"status.favourite": "Like",
|
"status.favourite": "Like",
|
||||||
"status.filtered": "Filtered",
|
"status.filtered": "Filtered",
|
||||||
|
"status.group": "Posted in {group}",
|
||||||
|
"status.group_mod_block": "Block @{name} from group",
|
||||||
|
"status.group_mod_delete": "Delete post from group",
|
||||||
|
"status.group_mod_kick": "Kick @{name} from group",
|
||||||
"status.interactions.favourites": "{count, plural, one {Like} other {Likes}}",
|
"status.interactions.favourites": "{count, plural, one {Like} other {Likes}}",
|
||||||
"status.interactions.quotes": "{count, plural, one {Quote} other {Quotes}}",
|
"status.interactions.quotes": "{count, plural, one {Quote} other {Quotes}}",
|
||||||
"status.interactions.reblogs": "{count, plural, one {Repost} other {Reposts}}",
|
"status.interactions.reblogs": "{count, plural, one {Repost} other {Reposts}}",
|
||||||
|
@ -1291,6 +1385,7 @@
|
||||||
"tabs_bar.all": "All",
|
"tabs_bar.all": "All",
|
||||||
"tabs_bar.dashboard": "Dashboard",
|
"tabs_bar.dashboard": "Dashboard",
|
||||||
"tabs_bar.fediverse": "Fediverse",
|
"tabs_bar.fediverse": "Fediverse",
|
||||||
|
"tabs_bar.groups": "Groups",
|
||||||
"tabs_bar.home": "Home",
|
"tabs_bar.home": "Home",
|
||||||
"tabs_bar.local": "Local",
|
"tabs_bar.local": "Local",
|
||||||
"tabs_bar.more": "More",
|
"tabs_bar.more": "More",
|
||||||
|
|
|
@ -247,6 +247,7 @@
|
||||||
"column.follow_requests": "Prośby o obserwację",
|
"column.follow_requests": "Prośby o obserwację",
|
||||||
"column.followers": "Obserwujący",
|
"column.followers": "Obserwujący",
|
||||||
"column.following": "Obserwowani",
|
"column.following": "Obserwowani",
|
||||||
|
"column.groups": "Grupy",
|
||||||
"column.home": "Strona główna",
|
"column.home": "Strona główna",
|
||||||
"column.import_data": "Importuj dane",
|
"column.import_data": "Importuj dane",
|
||||||
"column.info": "Informacje o serwerze",
|
"column.info": "Informacje o serwerze",
|
||||||
|
@ -279,6 +280,31 @@
|
||||||
"compose.edit_success": "Twój wpis został zedytowany",
|
"compose.edit_success": "Twój wpis został zedytowany",
|
||||||
"compose.invalid_schedule": "Musisz zaplanować wpis przynajmniej 5 minut wcześniej.",
|
"compose.invalid_schedule": "Musisz zaplanować wpis przynajmniej 5 minut wcześniej.",
|
||||||
"compose.submit_success": "Twój wpis został wysłany",
|
"compose.submit_success": "Twój wpis został wysłany",
|
||||||
|
"compose_event.create": "Utwórz",
|
||||||
|
"compose_event.edit_success": "Wydarzenie zostało zedytowane",
|
||||||
|
"compose_event.fields.approval_required": "Chcę ręcznie zatwierdzać prośby o dołączenie",
|
||||||
|
"compose_event.fields.banner_label": "Baner wydarzenia",
|
||||||
|
"compose_event.fields.description_hint": "Obsługiwana jest składnia Markdown",
|
||||||
|
"compose_event.fields.description_label": "Opis wydarzenia",
|
||||||
|
"compose_event.fields.description_placeholder": "Opis",
|
||||||
|
"compose_event.fields.end_time_label": "Data zakończenia wydarzenia",
|
||||||
|
"compose_event.fields.end_time_placeholder": "Wydarzenie kończy się…",
|
||||||
|
"compose_event.fields.has_end_time": "Wydarzenie ma datę zakończenia",
|
||||||
|
"compose_event.fields.location_label": "Miejsce wydarzenia",
|
||||||
|
"compose_event.fields.name_label": "Nazwa wydarzenia",
|
||||||
|
"compose_event.fields.name_placeholder": "Nazwa",
|
||||||
|
"compose_event.fields.start_time_label": "Data rozpoczęcia wydarzenia",
|
||||||
|
"compose_event.fields.start_time_placeholder": "Wydarzenie rozpoczyna się…",
|
||||||
|
"compose_event.participation_requests.authorize": "Przyjmij",
|
||||||
|
"compose_event.participation_requests.authorize_success": "Przyjęto użytkownika",
|
||||||
|
"compose_event.participation_requests.reject": "Odrzuć",
|
||||||
|
"compose_event.participation_requests.reject_success": "Odrzucono użytkownika",
|
||||||
|
"compose_event.reset_location": "Resetuj miejsce",
|
||||||
|
"compose_event.submit_success": "Wydarzenie zostało utworzone",
|
||||||
|
"compose_event.tabs.edit": "Edytuj szczegóły",
|
||||||
|
"compose_event.tabs.pending": "Zarządzaj prośbami",
|
||||||
|
"compose_event.update": "Aktualizuj",
|
||||||
|
"compose_event.upload_banner": "Wyślij obraz",
|
||||||
"compose_form.direct_message_warning": "Ten wpis będzie widoczny tylko dla wszystkich wspomnianych użytkowników.",
|
"compose_form.direct_message_warning": "Ten wpis będzie widoczny tylko dla wszystkich wspomnianych użytkowników.",
|
||||||
"compose_form.hashtag_warning": "Ten wpis nie będzie widoczny pod podanymi hashtagami, ponieważ jest oznaczony jako niewidoczny. Tylko publiczne wpisy mogą zostać znalezione z użyciem hashtagów.",
|
"compose_form.hashtag_warning": "Ten wpis nie będzie widoczny pod podanymi hashtagami, ponieważ jest oznaczony jako niewidoczny. Tylko publiczne wpisy mogą zostać znalezione z użyciem hashtagów.",
|
||||||
"compose_form.lock_disclaimer": "Twoje konto nie jest {locked}. Każdy, kto Cię obserwuje, może wyświetlać Twoje wpisy przeznaczone tylko dla obserwujących.",
|
"compose_form.lock_disclaimer": "Twoje konto nie jest {locked}. Każdy, kto Cię obserwuje, może wyświetlać Twoje wpisy przeznaczone tylko dla obserwujących.",
|
||||||
|
@ -344,6 +370,11 @@
|
||||||
"confirmations.domain_block.confirm": "Ukryj wszysyko z domeny",
|
"confirmations.domain_block.confirm": "Ukryj wszysyko z domeny",
|
||||||
"confirmations.domain_block.heading": "Zablokuj {domain}",
|
"confirmations.domain_block.heading": "Zablokuj {domain}",
|
||||||
"confirmations.domain_block.message": "Czy na pewno chcesz zablokować całą domenę {domain}? Zwykle lepszym rozwiązaniem jest blokada lub wyciszenie kilku użytkowników.",
|
"confirmations.domain_block.message": "Czy na pewno chcesz zablokować całą domenę {domain}? Zwykle lepszym rozwiązaniem jest blokada lub wyciszenie kilku użytkowników.",
|
||||||
|
"confirmations.leave_event.confirm": "Opuść wydarzenie",
|
||||||
|
"confirmations.leave_event.message": "Jeśli będziesz chciał(a) dołączyć do wydarzenia jeszcze raz, prośba będzie musiała zostać ponownie zatwierdzona. Czy chcesz kontynuować?",
|
||||||
|
"confirmations.leave_group.confirm": "Opuść",
|
||||||
|
"confirmations.leave_group.message": "Czy na pewno chcesz opuścić tę grupę?",
|
||||||
|
"confirmations.leave_group.heading": "Opuść grupę",
|
||||||
"confirmations.mute.confirm": "Wycisz",
|
"confirmations.mute.confirm": "Wycisz",
|
||||||
"confirmations.mute.heading": "Wycisz @{name}",
|
"confirmations.mute.heading": "Wycisz @{name}",
|
||||||
"confirmations.mute.message": "Czy na pewno chcesz wyciszyć {name}?",
|
"confirmations.mute.message": "Czy na pewno chcesz wyciszyć {name}?",
|
||||||
|
@ -494,6 +525,7 @@
|
||||||
"empty_column.filters": "Nie wyciszyłeś(-aś) jeszcze żadnego słowa.",
|
"empty_column.filters": "Nie wyciszyłeś(-aś) jeszcze żadnego słowa.",
|
||||||
"empty_column.follow_recommendations": "Wygląda na to, że nie można wygenerować dla Ciebie sugestii kont do obserwacji. Możesz spróbować użyć wyszukiwania aby odnaleźć ciekawe profile, lub przejrzeć trendujące hashtagi.",
|
"empty_column.follow_recommendations": "Wygląda na to, że nie można wygenerować dla Ciebie sugestii kont do obserwacji. Możesz spróbować użyć wyszukiwania aby odnaleźć ciekawe profile, lub przejrzeć trendujące hashtagi.",
|
||||||
"empty_column.follow_requests": "Nie masz żadnych próśb o możliwość obserwacji. Kiedy ktoś utworzy ją, pojawi się tutaj.",
|
"empty_column.follow_requests": "Nie masz żadnych próśb o możliwość obserwacji. Kiedy ktoś utworzy ją, pojawi się tutaj.",
|
||||||
|
"empty_column.group": "Nie ma wpisów w tej grupie.",
|
||||||
"empty_column.hashtag": "Nie ma wpisów oznaczonych tym hashtagiem. Możesz napisać pierwszy(-a)!",
|
"empty_column.hashtag": "Nie ma wpisów oznaczonych tym hashtagiem. Możesz napisać pierwszy(-a)!",
|
||||||
"empty_column.home": "Możesz też odwiedzić {public}, aby znaleźć innych użytkowników.",
|
"empty_column.home": "Możesz też odwiedzić {public}, aby znaleźć innych użytkowników.",
|
||||||
"empty_column.home.local_tab": "zakładkę {site_title}",
|
"empty_column.home.local_tab": "zakładkę {site_title}",
|
||||||
|
@ -508,6 +540,7 @@
|
||||||
"empty_column.remote": "Tu nic nie ma! Zaobserwuj użytkowników {instance}, aby wypełnić tę oś.",
|
"empty_column.remote": "Tu nic nie ma! Zaobserwuj użytkowników {instance}, aby wypełnić tę oś.",
|
||||||
"empty_column.scheduled_statuses": "Nie masz żadnych zaplanowanych wpisów. Kiedy dodasz jakiś, pojawi się on tutaj.",
|
"empty_column.scheduled_statuses": "Nie masz żadnych zaplanowanych wpisów. Kiedy dodasz jakiś, pojawi się on tutaj.",
|
||||||
"empty_column.search.accounts": "Brak wyników wyszukiwania osób dla „{term}”",
|
"empty_column.search.accounts": "Brak wyników wyszukiwania osób dla „{term}”",
|
||||||
|
"empty_column.search.groups": "Brak wyników wyszukiwania grup dla „{term}”",
|
||||||
"empty_column.search.hashtags": "Brak wyników wyszukiwania hashtagów dla „{term}”",
|
"empty_column.search.hashtags": "Brak wyników wyszukiwania hashtagów dla „{term}”",
|
||||||
"empty_column.search.statuses": "Brak wyników wyszukiwania wpisów dla „{term}”",
|
"empty_column.search.statuses": "Brak wyników wyszukiwania wpisów dla „{term}”",
|
||||||
"empty_column.test": "Testowa oś czasu jest pusta.",
|
"empty_column.test": "Testowa oś czasu jest pusta.",
|
||||||
|
@ -557,6 +590,22 @@
|
||||||
"gdpr.message": "{siteTitle} korzysta z ciasteczek sesji, które są niezbędne dla działania strony.",
|
"gdpr.message": "{siteTitle} korzysta z ciasteczek sesji, które są niezbędne dla działania strony.",
|
||||||
"gdpr.title": "{siteTitle} korzysta z ciasteczek",
|
"gdpr.title": "{siteTitle} korzysta z ciasteczek",
|
||||||
"getting_started.open_source_notice": "{code_name} jest oprogramowaniem o otwartym źródle. Możesz pomóc w rozwoju lub zgłaszać błędy na GitLabie tutaj: {code_link} (v{code_version}).",
|
"getting_started.open_source_notice": "{code_name} jest oprogramowaniem o otwartym źródle. Możesz pomóc w rozwoju lub zgłaszać błędy na GitLabie tutaj: {code_link} (v{code_version}).",
|
||||||
|
"group.admin_subheading": "Administratorzy grupy",
|
||||||
|
"groups.empty.title": "Brak grup",
|
||||||
|
"groups.empty.subtitle": "Odkrywaj grupy do których możesz dołączyć lub utwórz własną.",
|
||||||
|
"group.moderator_subheading": "Moderatorzy grupy",
|
||||||
|
"group.user_subheading": "Członkowie grupy",
|
||||||
|
"group.header.alt": "Nagłówek grupy",
|
||||||
|
"group.join": "Dołącz do grupy",
|
||||||
|
"group.leave": "Opuść grupę",
|
||||||
|
"group.manage": "Edytuj grupę",
|
||||||
|
"group.request_join": "Poproś o dołączenie do grupy",
|
||||||
|
"group.role.admin": "Administrator",
|
||||||
|
"group.role.moderator": "Moderator",
|
||||||
|
"group.privacy.locked": "Prywatna",
|
||||||
|
"group.privacy.public": "Publiczna",
|
||||||
|
"group.tabs.all": "Wszystko",
|
||||||
|
"group.tabs.members": "Członkowie",
|
||||||
"hashtag.column_header.tag_mode.all": "i {additional}",
|
"hashtag.column_header.tag_mode.all": "i {additional}",
|
||||||
"hashtag.column_header.tag_mode.any": "lub {additional}",
|
"hashtag.column_header.tag_mode.any": "lub {additional}",
|
||||||
"hashtag.column_header.tag_mode.none": "bez {additional}",
|
"hashtag.column_header.tag_mode.none": "bez {additional}",
|
||||||
|
@ -653,6 +702,21 @@
|
||||||
"login_external.errors.instance_fail": "Instancja zwróciła błąd.",
|
"login_external.errors.instance_fail": "Instancja zwróciła błąd.",
|
||||||
"login_external.errors.network_fail": "Połączenie nie powiodło się. Czy jest blokowane przez wtyczkę do przeglądarki?",
|
"login_external.errors.network_fail": "Połączenie nie powiodło się. Czy jest blokowane przez wtyczkę do przeglądarki?",
|
||||||
"login_form.header": "Zaloguj się",
|
"login_form.header": "Zaloguj się",
|
||||||
|
"manage_group.create": "Utwórz",
|
||||||
|
"manage_group.fields.name_label": "Nazwa grupy (wymagana)",
|
||||||
|
"manage_group.fields.name_placeholder": "Nazwa grupy",
|
||||||
|
"manage_group.fields.description_label": "Opis",
|
||||||
|
"manage_group.fields.description_placeholder": "Opis",
|
||||||
|
"manage_group.get_started": "Rozpocznijmy!",
|
||||||
|
"manage_group.next": "Dalej",
|
||||||
|
"manage_group.privacy.hint": "To ustawienie nie może zostać później zmienione.",
|
||||||
|
"manage_group.privacy.label": "Ustawienia prywatności",
|
||||||
|
"manage_group.privacy.public.hint": "Widoczna w mechanizmach odkrywania. Każdy może dołączyć.",
|
||||||
|
"manage_group.privacy.public.label": "Publiczna",
|
||||||
|
"manage_group.privacy.private.hint": "Widoczna w mechanizmach odkrywania. Użytkownicy mogą dołączyć po zatwierdzeniu ich prośby.",
|
||||||
|
"manage_group.privacy.private.label": "Prywatna (wymaga zatwierdzenia przez właściciela)",
|
||||||
|
"manage_group.tagline": "Grupy pozwalają łączyć ludzi o podobnych zainteresowaniach.",
|
||||||
|
"manage_group.update": "Aktualizuj",
|
||||||
"media_panel.empty_message": "Nie znaleziono mediów.",
|
"media_panel.empty_message": "Nie znaleziono mediów.",
|
||||||
"media_panel.title": "Media",
|
"media_panel.title": "Media",
|
||||||
"mfa.confirm.success_message": "Potwierdzono MFA",
|
"mfa.confirm.success_message": "Potwierdzono MFA",
|
||||||
|
@ -715,6 +779,8 @@
|
||||||
"navigation_bar.compose_edit": "Edytuj wpis",
|
"navigation_bar.compose_edit": "Edytuj wpis",
|
||||||
"navigation_bar.compose_quote": "Cytuj wpis",
|
"navigation_bar.compose_quote": "Cytuj wpis",
|
||||||
"navigation_bar.compose_reply": "Odpowiedz na wpis",
|
"navigation_bar.compose_reply": "Odpowiedz na wpis",
|
||||||
|
"navigation_bar.create_event": "Utwórz nowe wydarzenie",
|
||||||
|
"navigation_bar.create_group": "Utwórz grupę",
|
||||||
"navigation_bar.domain_blocks": "Ukryte domeny",
|
"navigation_bar.domain_blocks": "Ukryte domeny",
|
||||||
"navigation_bar.favourites": "Ulubione",
|
"navigation_bar.favourites": "Ulubione",
|
||||||
"navigation_bar.filters": "Wyciszone słowa",
|
"navigation_bar.filters": "Wyciszone słowa",
|
||||||
|
@ -723,10 +789,14 @@
|
||||||
"navigation_bar.in_reply_to": "W odpowiedzi do",
|
"navigation_bar.in_reply_to": "W odpowiedzi do",
|
||||||
"navigation_bar.invites": "Zaproszenia",
|
"navigation_bar.invites": "Zaproszenia",
|
||||||
"navigation_bar.logout": "Wyloguj",
|
"navigation_bar.logout": "Wyloguj",
|
||||||
|
"navigation_bar.edit_group": "Edytuj grupę",
|
||||||
"navigation_bar.mutes": "Wyciszeni użytkownicy",
|
"navigation_bar.mutes": "Wyciszeni użytkownicy",
|
||||||
"navigation_bar.preferences": "Preferencje",
|
"navigation_bar.preferences": "Preferencje",
|
||||||
"navigation_bar.profile_directory": "Katalog profilów",
|
"navigation_bar.profile_directory": "Katalog profilów",
|
||||||
"navigation_bar.soapbox_config": "Konfiguracja Soapbox",
|
"navigation_bar.soapbox_config": "Konfiguracja Soapbox",
|
||||||
|
"new_group_panel.action": "Utwórz grupę",
|
||||||
|
"new_group_panel.subtitle": "Nie możesz znaleźć tego, czego szukasz? Utwórz własną prywatną lub publiczną grupę.",
|
||||||
|
"new_group_panel.title": "Utwórz nową grupę",
|
||||||
"notification.favourite": "{name} dodał(a) Twój wpis do ulubionych",
|
"notification.favourite": "{name} dodał(a) Twój wpis do ulubionych",
|
||||||
"notification.follow": "{name} zaczął(-ęła) Cię obserwować",
|
"notification.follow": "{name} zaczął(-ęła) Cię obserwować",
|
||||||
"notification.follow_request": "{name} poprosił(a) Cię o możliwość obserwacji",
|
"notification.follow_request": "{name} poprosił(a) Cię o możliwość obserwacji",
|
||||||
|
@ -948,6 +1018,7 @@
|
||||||
"search.placeholder": "Szukaj",
|
"search.placeholder": "Szukaj",
|
||||||
"search_results.accounts": "Ludzie",
|
"search_results.accounts": "Ludzie",
|
||||||
"search_results.filter_message": "Szukasz wpisów autorstwa @{acct}.",
|
"search_results.filter_message": "Szukasz wpisów autorstwa @{acct}.",
|
||||||
|
"search_results.groups": "Grupy",
|
||||||
"search_results.hashtags": "Hashtagi",
|
"search_results.hashtags": "Hashtagi",
|
||||||
"search_results.statuses": "Wpisy",
|
"search_results.statuses": "Wpisy",
|
||||||
"security.codes.fail": "Nie udało się uzyskać zapasowych kodów",
|
"security.codes.fail": "Nie udało się uzyskać zapasowych kodów",
|
||||||
|
@ -1121,6 +1192,7 @@
|
||||||
"tabs_bar.all": "Wszystkie",
|
"tabs_bar.all": "Wszystkie",
|
||||||
"tabs_bar.dashboard": "Panel administracyjny",
|
"tabs_bar.dashboard": "Panel administracyjny",
|
||||||
"tabs_bar.fediverse": "Fediwersum",
|
"tabs_bar.fediverse": "Fediwersum",
|
||||||
|
"tabs_bar.groups": "Grupy",
|
||||||
"tabs_bar.home": "Strona główna",
|
"tabs_bar.home": "Strona główna",
|
||||||
"tabs_bar.local": "Lokalna",
|
"tabs_bar.local": "Lokalna",
|
||||||
"tabs_bar.more": "Więcej",
|
"tabs_bar.more": "Więcej",
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
/**
|
||||||
|
* Group relationship normalizer:
|
||||||
|
* Converts API group relationships into our internal format.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
Map as ImmutableMap,
|
||||||
|
Record as ImmutableRecord,
|
||||||
|
fromJS,
|
||||||
|
} from 'immutable';
|
||||||
|
|
||||||
|
export const GroupRelationshipRecord = ImmutableRecord({
|
||||||
|
id: '',
|
||||||
|
member: false,
|
||||||
|
requested: false,
|
||||||
|
role: null as 'admin' | 'moderator' | 'user' | null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const normalizeGroupRelationship = (relationship: Record<string, any>) => {
|
||||||
|
return GroupRelationshipRecord(
|
||||||
|
ImmutableMap(fromJS(relationship)),
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,152 @@
|
||||||
|
/**
|
||||||
|
* Group normalizer:
|
||||||
|
* Converts API groups into our internal format.
|
||||||
|
*/
|
||||||
|
import escapeTextContentForBrowser from 'escape-html';
|
||||||
|
import {
|
||||||
|
Map as ImmutableMap,
|
||||||
|
List as ImmutableList,
|
||||||
|
Record as ImmutableRecord,
|
||||||
|
fromJS,
|
||||||
|
} from 'immutable';
|
||||||
|
|
||||||
|
import emojify from 'soapbox/features/emoji/emoji';
|
||||||
|
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
|
||||||
|
import { unescapeHTML } from 'soapbox/utils/html';
|
||||||
|
import { makeEmojiMap } from 'soapbox/utils/normalizers';
|
||||||
|
|
||||||
|
import type { Emoji, GroupRelationship } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
export const GroupRecord = ImmutableRecord({
|
||||||
|
avatar: '',
|
||||||
|
avatar_static: '',
|
||||||
|
created_at: '',
|
||||||
|
display_name: '',
|
||||||
|
domain: '',
|
||||||
|
emojis: ImmutableList<Emoji>(),
|
||||||
|
header: '',
|
||||||
|
header_static: '',
|
||||||
|
id: '',
|
||||||
|
locked: false,
|
||||||
|
membership_required: false,
|
||||||
|
note: '',
|
||||||
|
statuses_visibility: 'public',
|
||||||
|
uri: '',
|
||||||
|
url: '',
|
||||||
|
|
||||||
|
// Internal fields
|
||||||
|
display_name_html: '',
|
||||||
|
note_emojified: '',
|
||||||
|
note_plain: '',
|
||||||
|
relationship: null as GroupRelationship | null,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Add avatar, if missing */
|
||||||
|
const normalizeAvatar = (group: ImmutableMap<string, any>) => {
|
||||||
|
const avatar = group.get('avatar');
|
||||||
|
const avatarStatic = group.get('avatar_static');
|
||||||
|
const missing = require('assets/images/avatar-missing.png');
|
||||||
|
|
||||||
|
return group.withMutations(group => {
|
||||||
|
group.set('avatar', avatar || avatarStatic || missing);
|
||||||
|
group.set('avatar_static', avatarStatic || avatar || missing);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Add header, if missing */
|
||||||
|
const normalizeHeader = (group: ImmutableMap<string, any>) => {
|
||||||
|
const header = group.get('header');
|
||||||
|
const headerStatic = group.get('header_static');
|
||||||
|
const missing = require('assets/images/header-missing.png');
|
||||||
|
|
||||||
|
return group.withMutations(group => {
|
||||||
|
group.set('header', header || headerStatic || missing);
|
||||||
|
group.set('header_static', headerStatic || header || missing);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Normalize emojis */
|
||||||
|
const normalizeEmojis = (entity: ImmutableMap<string, any>) => {
|
||||||
|
const emojis = entity.get('emojis', ImmutableList()).map(normalizeEmoji);
|
||||||
|
return entity.set('emojis', emojis);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Set display name from username, if applicable */
|
||||||
|
const fixDisplayName = (group: ImmutableMap<string, any>) => {
|
||||||
|
const displayName = group.get('display_name') || '';
|
||||||
|
return group.set('display_name', displayName.trim().length === 0 ? group.get('username') : displayName);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Emojification, etc */
|
||||||
|
const addInternalFields = (group: ImmutableMap<string, any>) => {
|
||||||
|
const emojiMap = makeEmojiMap(group.get('emojis'));
|
||||||
|
|
||||||
|
return group.withMutations((group: ImmutableMap<string, any>) => {
|
||||||
|
// Emojify group properties
|
||||||
|
group.merge({
|
||||||
|
display_name_html: emojify(escapeTextContentForBrowser(group.get('display_name')), emojiMap),
|
||||||
|
note_emojified: emojify(group.get('note', ''), emojiMap),
|
||||||
|
note_plain: unescapeHTML(group.get('note', '')),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emojify fields
|
||||||
|
group.update('fields', ImmutableList(), fields => {
|
||||||
|
return fields.map((field: ImmutableMap<string, any>) => {
|
||||||
|
return field.merge({
|
||||||
|
name_emojified: emojify(escapeTextContentForBrowser(field.get('name')), emojiMap),
|
||||||
|
value_emojified: emojify(field.get('value'), emojiMap),
|
||||||
|
value_plain: unescapeHTML(field.get('value')),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDomainFromURL = (group: ImmutableMap<string, any>): string => {
|
||||||
|
try {
|
||||||
|
const url = group.get('url');
|
||||||
|
return new URL(url).host;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const guessFqn = (group: ImmutableMap<string, any>): string => {
|
||||||
|
const acct = group.get('acct', '');
|
||||||
|
const [user, domain] = acct.split('@');
|
||||||
|
|
||||||
|
if (domain) {
|
||||||
|
return acct;
|
||||||
|
} else {
|
||||||
|
return [user, getDomainFromURL(group)].join('@');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeFqn = (group: ImmutableMap<string, any>) => {
|
||||||
|
const fqn = group.get('fqn') || guessFqn(group);
|
||||||
|
return group.set('fqn', fqn);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/** Rewrite `<p></p>` to empty string. */
|
||||||
|
const fixNote = (group: ImmutableMap<string, any>) => {
|
||||||
|
if (group.get('note') === '<p></p>') {
|
||||||
|
return group.set('note', '');
|
||||||
|
} else {
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeGroup = (group: Record<string, any>) => {
|
||||||
|
return GroupRecord(
|
||||||
|
ImmutableMap(fromJS(group)).withMutations(group => {
|
||||||
|
normalizeEmojis(group);
|
||||||
|
normalizeAvatar(group);
|
||||||
|
normalizeHeader(group);
|
||||||
|
normalizeFqn(group);
|
||||||
|
fixDisplayName(group);
|
||||||
|
fixNote(group);
|
||||||
|
addInternalFields(group);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
|
@ -9,6 +9,8 @@ export { ChatRecord, normalizeChat } from './chat';
|
||||||
export { ChatMessageRecord, normalizeChatMessage } from './chat-message';
|
export { ChatMessageRecord, normalizeChatMessage } from './chat-message';
|
||||||
export { EmojiRecord, normalizeEmoji } from './emoji';
|
export { EmojiRecord, normalizeEmoji } from './emoji';
|
||||||
export { FilterRecord, normalizeFilter } from './filter';
|
export { FilterRecord, normalizeFilter } from './filter';
|
||||||
|
export { GroupRecord, normalizeGroup } from './group';
|
||||||
|
export { GroupRelationshipRecord, normalizeGroupRelationship } from './group-relationship';
|
||||||
export { HistoryRecord, normalizeHistory } from './history';
|
export { HistoryRecord, normalizeHistory } from './history';
|
||||||
export { InstanceRecord, normalizeInstance } from './instance';
|
export { InstanceRecord, normalizeInstance } from './instance';
|
||||||
export { ListRecord, normalizeList } from './list';
|
export { ListRecord, normalizeList } from './list';
|
||||||
|
|
|
@ -17,8 +17,9 @@ import { normalizeMention } from 'soapbox/normalizers/mention';
|
||||||
import { normalizePoll } from 'soapbox/normalizers/poll';
|
import { normalizePoll } from 'soapbox/normalizers/poll';
|
||||||
|
|
||||||
import type { ReducerAccount } from 'soapbox/reducers/accounts';
|
import type { ReducerAccount } from 'soapbox/reducers/accounts';
|
||||||
import type { Account, Attachment, Card, Emoji, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities';
|
import type { Account, Attachment, Card, Emoji, Group, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
export type StatusApprovalStatus = 'pending' | 'approval' | 'rejected';
|
||||||
export type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct' | 'self';
|
export type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct' | 'self';
|
||||||
|
|
||||||
export type EventJoinMode = 'free' | 'restricted' | 'invite';
|
export type EventJoinMode = 'free' | 'restricted' | 'invite';
|
||||||
|
@ -40,6 +41,7 @@ export const EventRecord = ImmutableRecord({
|
||||||
export const StatusRecord = ImmutableRecord({
|
export const StatusRecord = ImmutableRecord({
|
||||||
account: null as EmbeddedEntity<Account | ReducerAccount>,
|
account: null as EmbeddedEntity<Account | ReducerAccount>,
|
||||||
application: null as ImmutableMap<string, any> | null,
|
application: null as ImmutableMap<string, any> | null,
|
||||||
|
approval_status: 'approved' as StatusApprovalStatus,
|
||||||
bookmarked: false,
|
bookmarked: false,
|
||||||
card: null as Card | null,
|
card: null as Card | null,
|
||||||
content: '',
|
content: '',
|
||||||
|
@ -48,7 +50,7 @@ export const StatusRecord = ImmutableRecord({
|
||||||
emojis: ImmutableList<Emoji>(),
|
emojis: ImmutableList<Emoji>(),
|
||||||
favourited: false,
|
favourited: false,
|
||||||
favourites_count: 0,
|
favourites_count: 0,
|
||||||
group: null as EmbeddedEntity<any>,
|
group: null as EmbeddedEntity<Group>,
|
||||||
in_reply_to_account_id: null as string | null,
|
in_reply_to_account_id: null as string | null,
|
||||||
in_reply_to_id: null as string | null,
|
in_reply_to_id: null as string | null,
|
||||||
id: '',
|
id: '',
|
||||||
|
|
|
@ -0,0 +1,104 @@
|
||||||
|
import React, { useCallback, useEffect } from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import { useRouteMatch } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { fetchGroup } from 'soapbox/actions/groups';
|
||||||
|
import MissingIndicator from 'soapbox/components/missing-indicator';
|
||||||
|
import { Column, Layout } from 'soapbox/components/ui';
|
||||||
|
import GroupHeader from 'soapbox/features/group/components/group-header';
|
||||||
|
import LinkFooter from 'soapbox/features/ui/components/link-footer';
|
||||||
|
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
|
||||||
|
import {
|
||||||
|
CtaBanner,
|
||||||
|
GroupMediaPanel,
|
||||||
|
SignUpPanel,
|
||||||
|
} from 'soapbox/features/ui/util/async-components';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
import { makeGetGroup } from 'soapbox/selectors';
|
||||||
|
|
||||||
|
import { Tabs } from '../components/ui';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
all: { id: 'group.tabs.all', defaultMessage: 'All' },
|
||||||
|
members: { id: 'group.tabs.members', defaultMessage: 'Members' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IGroupPage {
|
||||||
|
params?: {
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page to display a group. */
|
||||||
|
const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const match = useRouteMatch();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const id = params?.id || '';
|
||||||
|
|
||||||
|
const getGroup = useCallback(makeGetGroup(), []);
|
||||||
|
const group = useAppSelector(state => getGroup(state, id));
|
||||||
|
const me = useAppSelector(state => state.me);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchGroup(id));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if ((group as any) === false) {
|
||||||
|
return (
|
||||||
|
<MissingIndicator />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
text: intl.formatMessage(messages.all),
|
||||||
|
to: `/groups/${group?.id}`,
|
||||||
|
name: '/groups/:id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: intl.formatMessage(messages.members),
|
||||||
|
to: `/groups/${group?.id}/members`,
|
||||||
|
name: '/groups/:id/members',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Layout.Main>
|
||||||
|
<Column label={group ? group.display_name : ''} withHeader={false}>
|
||||||
|
<GroupHeader group={group} />
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
items={items}
|
||||||
|
activeItem={match.path}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
{!me && (
|
||||||
|
<BundleContainer fetchComponent={CtaBanner}>
|
||||||
|
{Component => <Component key='cta-banner' />}
|
||||||
|
</BundleContainer>
|
||||||
|
)}
|
||||||
|
</Layout.Main>
|
||||||
|
|
||||||
|
<Layout.Aside>
|
||||||
|
{!me && (
|
||||||
|
<BundleContainer fetchComponent={SignUpPanel}>
|
||||||
|
{Component => <Component key='sign-up-panel' />}
|
||||||
|
</BundleContainer>
|
||||||
|
)}
|
||||||
|
<BundleContainer fetchComponent={GroupMediaPanel}>
|
||||||
|
{Component => <Component group={group} />}
|
||||||
|
</BundleContainer>
|
||||||
|
<LinkFooter key='link-footer' />
|
||||||
|
</Layout.Aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupPage;
|
|
@ -0,0 +1,47 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Column, Layout } from 'soapbox/components/ui';
|
||||||
|
import LinkFooter from 'soapbox/features/ui/components/link-footer';
|
||||||
|
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
|
||||||
|
import {
|
||||||
|
NewGroupPanel,
|
||||||
|
CtaBanner,
|
||||||
|
} from 'soapbox/features/ui/util/async-components';
|
||||||
|
import { useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
interface IGroupsPage {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page to display groups. */
|
||||||
|
const GroupsPage: React.FC<IGroupsPage> = ({ children }) => {
|
||||||
|
const me = useAppSelector(state => state.me);
|
||||||
|
// const match = useRouteMatch();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Layout.Main>
|
||||||
|
<Column withHeader={false}>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
{!me && (
|
||||||
|
<BundleContainer fetchComponent={CtaBanner}>
|
||||||
|
{Component => <Component key='cta-banner' />}
|
||||||
|
</BundleContainer>
|
||||||
|
)}
|
||||||
|
</Layout.Main>
|
||||||
|
|
||||||
|
<Layout.Aside>
|
||||||
|
<BundleContainer fetchComponent={NewGroupPanel}>
|
||||||
|
{Component => <Component key='new-group-panel' />}
|
||||||
|
</BundleContainer>
|
||||||
|
<LinkFooter key='link-footer' />
|
||||||
|
</Layout.Aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupsPage;
|
|
@ -8,6 +8,7 @@ import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from 'soapbox/actions/importer';
|
||||||
import { STREAMING_FOLLOW_RELATIONSHIPS_UPDATE } from 'soapbox/actions/streaming';
|
import { STREAMING_FOLLOW_RELATIONSHIPS_UPDATE } from 'soapbox/actions/streaming';
|
||||||
|
|
||||||
import type { AnyAction } from 'redux';
|
import type { AnyAction } from 'redux';
|
||||||
|
import type { APIEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
const CounterRecord = ImmutableRecord({
|
const CounterRecord = ImmutableRecord({
|
||||||
followers_count: 0,
|
followers_count: 0,
|
||||||
|
@ -17,7 +18,6 @@ const CounterRecord = ImmutableRecord({
|
||||||
|
|
||||||
type Counter = ReturnType<typeof CounterRecord>;
|
type Counter = ReturnType<typeof CounterRecord>;
|
||||||
type State = ImmutableMap<string, Counter>;
|
type State = ImmutableMap<string, Counter>;
|
||||||
type APIEntity = Record<string, any>;
|
|
||||||
type APIEntities = Array<APIEntity>;
|
type APIEntities = Array<APIEntity>;
|
||||||
|
|
||||||
const normalizeAccount = (state: State, account: APIEntity) => state.set(account.id, CounterRecord({
|
const normalizeAccount = (state: State, account: APIEntity) => state.set(account.id, CounterRecord({
|
||||||
|
|
|
@ -12,10 +12,11 @@ import type { AnyAction } from 'redux';
|
||||||
|
|
||||||
const MetaRecord = ImmutableRecord({
|
const MetaRecord = ImmutableRecord({
|
||||||
pleroma: ImmutableMap<string, any>(),
|
pleroma: ImmutableMap<string, any>(),
|
||||||
|
role: null as ImmutableMap<string, any> | null,
|
||||||
source: ImmutableMap<string, any>(),
|
source: ImmutableMap<string, any>(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type Meta = ReturnType<typeof MetaRecord>;
|
export type Meta = ReturnType<typeof MetaRecord>;
|
||||||
type State = ImmutableMap<string, Meta>;
|
type State = ImmutableMap<string, Meta>;
|
||||||
|
|
||||||
const importAccount = (state: State, account: ImmutableMap<string, any>) => {
|
const importAccount = (state: State, account: ImmutableMap<string, any>) => {
|
||||||
|
@ -23,6 +24,7 @@ const importAccount = (state: State, account: ImmutableMap<string, any>) => {
|
||||||
|
|
||||||
return state.set(accountId, MetaRecord({
|
return state.set(accountId, MetaRecord({
|
||||||
pleroma: account.get('pleroma', ImmutableMap()).delete('settings_store'),
|
pleroma: account.get('pleroma', ImmutableMap()).delete('settings_store'),
|
||||||
|
role: account.get('role', null),
|
||||||
source: account.get('source', ImmutableMap()),
|
source: account.get('source', ImmutableMap()),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
|
@ -39,10 +39,10 @@ import { normalizeAccount } from 'soapbox/normalizers/account';
|
||||||
import { normalizeId } from 'soapbox/utils/normalizers';
|
import { normalizeId } from 'soapbox/utils/normalizers';
|
||||||
|
|
||||||
import type { AnyAction } from 'redux';
|
import type { AnyAction } from 'redux';
|
||||||
|
import type { APIEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
type AccountRecord = ReturnType<typeof normalizeAccount>;
|
type AccountRecord = ReturnType<typeof normalizeAccount>;
|
||||||
type AccountMap = ImmutableMap<string, any>;
|
type AccountMap = ImmutableMap<string, any>;
|
||||||
type APIEntity = Record<string, any>;
|
|
||||||
type APIEntities = Array<APIEntity>;
|
type APIEntities = Array<APIEntity>;
|
||||||
|
|
||||||
export interface ReducerAccount extends AccountRecord {
|
export interface ReducerAccount extends AccountRecord {
|
||||||
|
|
|
@ -7,6 +7,7 @@ import {
|
||||||
import { ADMIN_LOG_FETCH_SUCCESS } from 'soapbox/actions/admin';
|
import { ADMIN_LOG_FETCH_SUCCESS } from 'soapbox/actions/admin';
|
||||||
|
|
||||||
import type { AnyAction } from 'redux';
|
import type { AnyAction } from 'redux';
|
||||||
|
import type { APIEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
export const LogEntryRecord = ImmutableRecord({
|
export const LogEntryRecord = ImmutableRecord({
|
||||||
data: ImmutableMap<string, any>(),
|
data: ImmutableMap<string, any>(),
|
||||||
|
@ -23,7 +24,6 @@ const ReducerRecord = ImmutableRecord({
|
||||||
|
|
||||||
type LogEntry = ReturnType<typeof LogEntryRecord>;
|
type LogEntry = ReturnType<typeof LogEntryRecord>;
|
||||||
type State = ReturnType<typeof ReducerRecord>;
|
type State = ReturnType<typeof ReducerRecord>;
|
||||||
type APIEntity = Record<string, any>;
|
|
||||||
type APIEntities = Array<APIEntity>;
|
type APIEntities = Array<APIEntity>;
|
||||||
|
|
||||||
const parseItems = (items: APIEntities) => {
|
const parseItems = (items: APIEntities) => {
|
||||||
|
|
|
@ -11,8 +11,8 @@ import {
|
||||||
import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming';
|
import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming';
|
||||||
|
|
||||||
import type { AnyAction } from 'redux';
|
import type { AnyAction } from 'redux';
|
||||||
|
import type { APIEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
type APIEntity = Record<string, any>;
|
|
||||||
type APIEntities = Array<APIEntity>;
|
type APIEntities = Array<APIEntity>;
|
||||||
|
|
||||||
type State = ImmutableMap<string, ImmutableOrderedSet<string>>;
|
type State = ImmutableMap<string, ImmutableOrderedSet<string>>;
|
||||||
|
|
|
@ -13,9 +13,9 @@ import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming';
|
||||||
import { normalizeChatMessage } from 'soapbox/normalizers';
|
import { normalizeChatMessage } from 'soapbox/normalizers';
|
||||||
|
|
||||||
import type { AnyAction } from 'redux';
|
import type { AnyAction } from 'redux';
|
||||||
|
import type { APIEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
type ChatMessageRecord = ReturnType<typeof normalizeChatMessage>;
|
type ChatMessageRecord = ReturnType<typeof normalizeChatMessage>;
|
||||||
type APIEntity = Record<string, any>;
|
|
||||||
type APIEntities = Array<APIEntity>;
|
type APIEntities = Array<APIEntity>;
|
||||||
|
|
||||||
type State = ImmutableMap<string, ChatMessageRecord>;
|
type State = ImmutableMap<string, ChatMessageRecord>;
|
||||||
|
|
|
@ -14,9 +14,9 @@ import { normalizeChat } from 'soapbox/normalizers';
|
||||||
import { normalizeId } from 'soapbox/utils/normalizers';
|
import { normalizeId } from 'soapbox/utils/normalizers';
|
||||||
|
|
||||||
import type { AnyAction } from 'redux';
|
import type { AnyAction } from 'redux';
|
||||||
|
import type { APIEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
type ChatRecord = ReturnType<typeof normalizeChat>;
|
type ChatRecord = ReturnType<typeof normalizeChat>;
|
||||||
type APIEntity = Record<string, any>;
|
|
||||||
type APIEntities = Array<APIEntity>;
|
type APIEntities = Array<APIEntity>;
|
||||||
|
|
||||||
export interface ReducerChat extends ChatRecord {
|
export interface ReducerChat extends ChatRecord {
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
COMPOSE_REPLY_CANCEL,
|
COMPOSE_REPLY_CANCEL,
|
||||||
COMPOSE_QUOTE,
|
COMPOSE_QUOTE,
|
||||||
COMPOSE_QUOTE_CANCEL,
|
COMPOSE_QUOTE_CANCEL,
|
||||||
|
COMPOSE_GROUP_POST,
|
||||||
COMPOSE_DIRECT,
|
COMPOSE_DIRECT,
|
||||||
COMPOSE_MENTION,
|
COMPOSE_MENTION,
|
||||||
COMPOSE_SUBMIT_REQUEST,
|
COMPOSE_SUBMIT_REQUEST,
|
||||||
|
@ -78,6 +79,7 @@ export const ReducerCompose = ImmutableRecord({
|
||||||
caretPosition: null as number | null,
|
caretPosition: null as number | null,
|
||||||
content_type: 'text/plain',
|
content_type: 'text/plain',
|
||||||
focusDate: null as Date | null,
|
focusDate: null as Date | null,
|
||||||
|
group_id: null as string | null,
|
||||||
idempotencyKey: '',
|
idempotencyKey: '',
|
||||||
id: null as string | null,
|
id: null as string | null,
|
||||||
in_reply_to: null as string | null,
|
in_reply_to: null as string | null,
|
||||||
|
@ -202,6 +204,9 @@ const insertEmoji = (compose: Compose, position: number, emojiData: Emoji, needs
|
||||||
|
|
||||||
const privacyPreference = (a: string, b: string) => {
|
const privacyPreference = (a: string, b: string) => {
|
||||||
const order = ['public', 'unlisted', 'private', 'direct'];
|
const order = ['public', 'unlisted', 'private', 'direct'];
|
||||||
|
|
||||||
|
if (a === 'group') return a;
|
||||||
|
|
||||||
return order[Math.max(order.indexOf(a), order.indexOf(b), 0)];
|
return order[Math.max(order.indexOf(a), order.indexOf(b), 0)];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -309,6 +314,7 @@ export default function compose(state = initialState, action: AnyAction) {
|
||||||
return updateCompose(state, action.id, compose => compose.withMutations(map => {
|
return updateCompose(state, action.id, compose => compose.withMutations(map => {
|
||||||
const defaultCompose = state.get('default')!;
|
const defaultCompose = state.get('default')!;
|
||||||
|
|
||||||
|
map.set('group_id', action.status.getIn(['group', 'id']) || action.status.get('group'));
|
||||||
map.set('in_reply_to', action.status.get('id'));
|
map.set('in_reply_to', action.status.get('id'));
|
||||||
map.set('to', action.explicitAddressing ? statusToMentionsArray(action.status, action.account) : ImmutableOrderedSet<string>());
|
map.set('to', action.explicitAddressing ? statusToMentionsArray(action.status, action.account) : ImmutableOrderedSet<string>());
|
||||||
map.set('text', !action.explicitAddressing ? statusToTextMentions(action.status, action.account) : '');
|
map.set('text', !action.explicitAddressing ? statusToTextMentions(action.status, action.account) : '');
|
||||||
|
@ -351,6 +357,10 @@ export default function compose(state = initialState, action: AnyAction) {
|
||||||
return updateCompose(state, action.id, () => state.get('default')!.withMutations(map => {
|
return updateCompose(state, action.id, () => state.get('default')!.withMutations(map => {
|
||||||
map.set('idempotencyKey', uuid());
|
map.set('idempotencyKey', uuid());
|
||||||
map.set('in_reply_to', action.id.startsWith('reply:') ? action.id.slice(6) : null);
|
map.set('in_reply_to', action.id.startsWith('reply:') ? action.id.slice(6) : null);
|
||||||
|
if (action.id.startsWith('group:')) {
|
||||||
|
map.set('privacy', 'group');
|
||||||
|
map.set('group_id', action.id.slice(6));
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
case COMPOSE_SUBMIT_FAIL:
|
case COMPOSE_SUBMIT_FAIL:
|
||||||
return updateCompose(state, action.id, compose => compose.set('is_submitting', false));
|
return updateCompose(state, action.id, compose => compose.set('is_submitting', false));
|
||||||
|
@ -381,6 +391,14 @@ export default function compose(state = initialState, action: AnyAction) {
|
||||||
map.set('caretPosition', null);
|
map.set('caretPosition', null);
|
||||||
map.set('idempotencyKey', uuid());
|
map.set('idempotencyKey', uuid());
|
||||||
}));
|
}));
|
||||||
|
case COMPOSE_GROUP_POST:
|
||||||
|
return updateCompose(state, action.id, compose => compose.withMutations(map => {
|
||||||
|
map.set('privacy', 'group');
|
||||||
|
map.set('group_id', action.group_id);
|
||||||
|
map.set('focusDate', new Date());
|
||||||
|
map.set('caretPosition', null);
|
||||||
|
map.set('idempotencyKey', uuid());
|
||||||
|
}));
|
||||||
case COMPOSE_SUGGESTIONS_CLEAR:
|
case COMPOSE_SUGGESTIONS_CLEAR:
|
||||||
return updateCompose(state, action.id, compose => compose.update('suggestions', list => list?.clear()).set('suggestion_token', null));
|
return updateCompose(state, action.id, compose => compose.update('suggestions', list => list?.clear()).set('suggestion_token', null));
|
||||||
case COMPOSE_SUGGESTIONS_READY:
|
case COMPOSE_SUGGESTIONS_READY:
|
||||||
|
@ -427,6 +445,7 @@ export default function compose(state = initialState, action: AnyAction) {
|
||||||
map.set('idempotencyKey', uuid());
|
map.set('idempotencyKey', uuid());
|
||||||
map.set('content_type', action.contentType || 'text/plain');
|
map.set('content_type', action.contentType || 'text/plain');
|
||||||
map.set('quote', action.status.get('quote'));
|
map.set('quote', action.status.get('quote'));
|
||||||
|
map.set('group_id', action.status.get('group'));
|
||||||
|
|
||||||
if (action.v?.software === PLEROMA && action.withRedraft && hasIntegerMediaIds(action.status)) {
|
if (action.v?.software === PLEROMA && action.withRedraft && hasIntegerMediaIds(action.status)) {
|
||||||
map.set('media_attachments', ImmutableList());
|
map.set('media_attachments', ImmutableList());
|
||||||
|
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { Record as ImmutableRecord } from 'immutable';
|
||||||
|
|
||||||
|
import {
|
||||||
|
GROUP_EDITOR_RESET,
|
||||||
|
GROUP_EDITOR_TITLE_CHANGE,
|
||||||
|
GROUP_EDITOR_DESCRIPTION_CHANGE,
|
||||||
|
GROUP_EDITOR_PRIVACY_CHANGE,
|
||||||
|
GROUP_EDITOR_MEDIA_CHANGE,
|
||||||
|
GROUP_CREATE_REQUEST,
|
||||||
|
GROUP_CREATE_FAIL,
|
||||||
|
GROUP_CREATE_SUCCESS,
|
||||||
|
GROUP_UPDATE_REQUEST,
|
||||||
|
GROUP_UPDATE_FAIL,
|
||||||
|
GROUP_UPDATE_SUCCESS,
|
||||||
|
GROUP_EDITOR_SET,
|
||||||
|
} from 'soapbox/actions/groups';
|
||||||
|
|
||||||
|
import type { AnyAction } from 'redux';
|
||||||
|
|
||||||
|
const ReducerRecord = ImmutableRecord({
|
||||||
|
groupId: null as string | null,
|
||||||
|
progress: 0,
|
||||||
|
isUploading: false,
|
||||||
|
isSubmitting: false,
|
||||||
|
isChanged: false,
|
||||||
|
displayName: '',
|
||||||
|
note: '',
|
||||||
|
avatar: null as File | null,
|
||||||
|
header: null as File | null,
|
||||||
|
locked: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
type State = ReturnType<typeof ReducerRecord>;
|
||||||
|
|
||||||
|
export default function groupEditor(state: State = ReducerRecord(), action: AnyAction) {
|
||||||
|
switch (action.type) {
|
||||||
|
case GROUP_EDITOR_RESET:
|
||||||
|
return ReducerRecord();
|
||||||
|
case GROUP_EDITOR_SET:
|
||||||
|
return state.withMutations(map => {
|
||||||
|
map.set('groupId', action.group.id);
|
||||||
|
map.set('displayName', action.group.display_name);
|
||||||
|
map.set('note', action.group.note);
|
||||||
|
});
|
||||||
|
case GROUP_EDITOR_TITLE_CHANGE:
|
||||||
|
return state.withMutations(map => {
|
||||||
|
map.set('displayName', action.value);
|
||||||
|
map.set('isChanged', true);
|
||||||
|
});
|
||||||
|
case GROUP_EDITOR_DESCRIPTION_CHANGE:
|
||||||
|
return state.withMutations(map => {
|
||||||
|
map.set('note', action.value);
|
||||||
|
map.set('isChanged', true);
|
||||||
|
});
|
||||||
|
case GROUP_EDITOR_PRIVACY_CHANGE:
|
||||||
|
return state.withMutations(map => {
|
||||||
|
map.set('locked', action.value);
|
||||||
|
map.set('isChanged', true);
|
||||||
|
});
|
||||||
|
case GROUP_EDITOR_MEDIA_CHANGE:
|
||||||
|
return state.set(action.mediaType, action.value);
|
||||||
|
case GROUP_CREATE_REQUEST:
|
||||||
|
case GROUP_UPDATE_REQUEST:
|
||||||
|
return state.withMutations(map => {
|
||||||
|
map.set('isSubmitting', true);
|
||||||
|
map.set('isChanged', false);
|
||||||
|
});
|
||||||
|
case GROUP_CREATE_FAIL:
|
||||||
|
case GROUP_UPDATE_FAIL:
|
||||||
|
return state.set('isSubmitting', false);
|
||||||
|
case GROUP_CREATE_SUCCESS:
|
||||||
|
case GROUP_UPDATE_SUCCESS:
|
||||||
|
return state.withMutations(map => {
|
||||||
|
map.set('isSubmitting', false);
|
||||||
|
map.set('groupId', action.group.id);
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet, Record as ImmutableRecord } from 'immutable';
|
||||||
|
|
||||||
|
import {
|
||||||
|
GROUP_DELETE_SUCCESS,
|
||||||
|
GROUP_MEMBERSHIPS_FETCH_REQUEST,
|
||||||
|
GROUP_MEMBERSHIPS_FETCH_FAIL,
|
||||||
|
GROUP_MEMBERSHIPS_FETCH_SUCCESS,
|
||||||
|
GROUP_MEMBERSHIPS_EXPAND_REQUEST,
|
||||||
|
GROUP_MEMBERSHIPS_EXPAND_FAIL,
|
||||||
|
GROUP_MEMBERSHIPS_EXPAND_SUCCESS,
|
||||||
|
GROUP_PROMOTE_SUCCESS,
|
||||||
|
GROUP_DEMOTE_SUCCESS,
|
||||||
|
GROUP_KICK_SUCCESS,
|
||||||
|
GROUP_BLOCK_SUCCESS,
|
||||||
|
} from 'soapbox/actions/groups';
|
||||||
|
|
||||||
|
import type { AnyAction } from 'redux';
|
||||||
|
import type { APIEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
const ListRecord = ImmutableRecord({
|
||||||
|
next: null as string | null,
|
||||||
|
isLoading: false,
|
||||||
|
items: ImmutableOrderedSet<string>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ReducerRecord = ImmutableRecord({
|
||||||
|
admin: ImmutableMap<string, List>({}),
|
||||||
|
moderator: ImmutableMap<string, List>({}),
|
||||||
|
user: ImmutableMap<string, List>({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type GroupRole = 'admin' | 'moderator' | 'user';
|
||||||
|
export type List = ReturnType<typeof ListRecord>;
|
||||||
|
type State = ReturnType<typeof ReducerRecord>;
|
||||||
|
|
||||||
|
const normalizeList = (state: State, path: string[], memberships: APIEntity[], next: string | null) => {
|
||||||
|
return state.setIn(path, ListRecord({
|
||||||
|
next,
|
||||||
|
items: ImmutableOrderedSet(memberships.map(item => item.account.id)),
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendToList = (state: State, path: string[], memberships: APIEntity[], next: string | null) => {
|
||||||
|
return state.updateIn(path, map => {
|
||||||
|
return (map as List).set('next', next).set('isLoading', false).update('items', list => list.concat(memberships.map(item => item.account.id)));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateLists = (state: State, groupId: string, memberships: APIEntity[]) => {
|
||||||
|
const updateList = (state: State, role: string, membership: APIEntity) => {
|
||||||
|
if (role === membership.role) {
|
||||||
|
return state.updateIn([role, groupId], map => (map as List).update('items', set => set.add(membership.account.id)));
|
||||||
|
} else {
|
||||||
|
return state.updateIn([role, groupId], map => (map as List).update('items', set => set.delete(membership.account.id)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
memberships.forEach(membership => {
|
||||||
|
state = updateList(state, 'admin', membership);
|
||||||
|
state = updateList(state, 'moderator', membership);
|
||||||
|
state = updateList(state, 'user', membership);
|
||||||
|
});
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFromList = (state: State, path: string[], accountId: string) => {
|
||||||
|
return state.updateIn(path, map => {
|
||||||
|
return (map as List).update('items', set => set.delete(accountId));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function groupMemberships(state: State = ReducerRecord(), action: AnyAction) {
|
||||||
|
switch (action.type) {
|
||||||
|
case GROUP_DELETE_SUCCESS:
|
||||||
|
return state.deleteIn(['admin', action.id]).deleteIn(['moderator', action.id]).deleteIn(['user', action.id]);
|
||||||
|
case GROUP_MEMBERSHIPS_FETCH_REQUEST:
|
||||||
|
case GROUP_MEMBERSHIPS_EXPAND_REQUEST:
|
||||||
|
return state.updateIn([action.role, action.id], map => (map as List || ListRecord()).set('isLoading', true));
|
||||||
|
case GROUP_MEMBERSHIPS_FETCH_FAIL:
|
||||||
|
case GROUP_MEMBERSHIPS_EXPAND_FAIL:
|
||||||
|
return state.updateIn([action.role, action.id], map => (map as List || ListRecord()).set('isLoading', false));
|
||||||
|
case GROUP_MEMBERSHIPS_FETCH_SUCCESS:
|
||||||
|
return normalizeList(state, [action.role, action.id], action.memberships, action.next);
|
||||||
|
case GROUP_MEMBERSHIPS_EXPAND_SUCCESS:
|
||||||
|
return appendToList(state, [action.role, action.id], action.memberships, action.next);
|
||||||
|
case GROUP_PROMOTE_SUCCESS:
|
||||||
|
case GROUP_DEMOTE_SUCCESS:
|
||||||
|
return updateLists(state, action.groupId, action.memberships);
|
||||||
|
case GROUP_KICK_SUCCESS:
|
||||||
|
case GROUP_BLOCK_SUCCESS:
|
||||||
|
state = removeFromList(state, ['admin', action.groupId], action.accountId);
|
||||||
|
state = removeFromList(state, ['moderator', action.groupId], action.accountId);
|
||||||
|
state = removeFromList(state, ['user', action.groupId], action.accountId);
|
||||||
|
return state;
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { Map as ImmutableMap } from 'immutable';
|
||||||
|
|
||||||
|
import {
|
||||||
|
GROUP_CREATE_SUCCESS,
|
||||||
|
GROUP_UPDATE_SUCCESS,
|
||||||
|
GROUP_DELETE_SUCCESS,
|
||||||
|
GROUP_RELATIONSHIPS_FETCH_SUCCESS,
|
||||||
|
GROUP_JOIN_REQUEST,
|
||||||
|
GROUP_JOIN_SUCCESS,
|
||||||
|
GROUP_JOIN_FAIL,
|
||||||
|
GROUP_LEAVE_REQUEST,
|
||||||
|
GROUP_LEAVE_SUCCESS,
|
||||||
|
GROUP_LEAVE_FAIL,
|
||||||
|
} from 'soapbox/actions/groups';
|
||||||
|
import { normalizeGroupRelationship } from 'soapbox/normalizers';
|
||||||
|
|
||||||
|
import type { AnyAction } from 'redux';
|
||||||
|
import type { APIEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
type GroupRelationshipRecord = ReturnType<typeof normalizeGroupRelationship>;
|
||||||
|
type APIEntities = Array<APIEntity>;
|
||||||
|
|
||||||
|
type State = ImmutableMap<string, GroupRelationshipRecord>;
|
||||||
|
|
||||||
|
const normalizeRelationships = (state: State, relationships: APIEntities) => {
|
||||||
|
relationships.forEach(relationship => {
|
||||||
|
state = state.set(relationship.id, normalizeGroupRelationship(relationship));
|
||||||
|
});
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function groupRelationships(state: State = ImmutableMap(), action: AnyAction) {
|
||||||
|
switch (action.type) {
|
||||||
|
case GROUP_CREATE_SUCCESS:
|
||||||
|
case GROUP_UPDATE_SUCCESS:
|
||||||
|
return state.set(action.group.id, normalizeGroupRelationship({ id: action.group.id, member: true, requested: false, role: 'admin' }));
|
||||||
|
case GROUP_DELETE_SUCCESS:
|
||||||
|
return state.delete(action.id);
|
||||||
|
case GROUP_JOIN_REQUEST:
|
||||||
|
return state.getIn([action.id, 'member']) ? state : state.setIn([action.id, action.locked ? 'requested' : 'member'], true);
|
||||||
|
case GROUP_JOIN_FAIL:
|
||||||
|
return state.setIn([action.id, action.locked ? 'requested' : 'member'], false);
|
||||||
|
case GROUP_LEAVE_REQUEST:
|
||||||
|
return state.setIn([action.id, 'member'], false);
|
||||||
|
case GROUP_LEAVE_FAIL:
|
||||||
|
return state.setIn([action.id, 'member'], true);
|
||||||
|
case GROUP_JOIN_SUCCESS:
|
||||||
|
case GROUP_LEAVE_SUCCESS:
|
||||||
|
return normalizeRelationships(state, [action.relationship]);
|
||||||
|
case GROUP_RELATIONSHIPS_FETCH_SUCCESS:
|
||||||
|
return normalizeRelationships(state, action.relationships);
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { Map as ImmutableMap, Record as ImmutableRecord } from 'immutable';
|
||||||
|
|
||||||
|
import { GROUP_FETCH_FAIL, GROUP_DELETE_SUCCESS, GROUP_FETCH_REQUEST } from 'soapbox/actions/groups';
|
||||||
|
import { GROUPS_IMPORT } from 'soapbox/actions/importer';
|
||||||
|
import { normalizeGroup } from 'soapbox/normalizers';
|
||||||
|
|
||||||
|
import type { AnyAction } from 'redux';
|
||||||
|
import type { APIEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
type GroupRecord = ReturnType<typeof normalizeGroup>;
|
||||||
|
type APIEntities = Array<APIEntity>;
|
||||||
|
|
||||||
|
const ReducerRecord = ImmutableRecord({
|
||||||
|
isLoading: true,
|
||||||
|
items: ImmutableMap<string, GroupRecord | false>({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type State = ReturnType<typeof ReducerRecord>;
|
||||||
|
|
||||||
|
const normalizeGroups = (state: State, groups: APIEntities) =>
|
||||||
|
state.update('items', items =>
|
||||||
|
groups.reduce((items: ImmutableMap<string, GroupRecord | false>, group) =>
|
||||||
|
items.set(group.id, normalizeGroup(group)), items),
|
||||||
|
).set('isLoading', false);
|
||||||
|
|
||||||
|
export default function groups(state: State = ReducerRecord(), action: AnyAction) {
|
||||||
|
switch (action.type) {
|
||||||
|
case GROUPS_IMPORT:
|
||||||
|
return normalizeGroups(state, action.groups);
|
||||||
|
case GROUP_FETCH_REQUEST:
|
||||||
|
return state.set('isLoading', true);
|
||||||
|
case GROUP_DELETE_SUCCESS:
|
||||||
|
case GROUP_FETCH_FAIL:
|
||||||
|
return state
|
||||||
|
.setIn(['items', action.id], false)
|
||||||
|
.set('isLoading', false);
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
|
@ -26,6 +26,10 @@ import custom_emojis from './custom-emojis';
|
||||||
import domain_lists from './domain-lists';
|
import domain_lists from './domain-lists';
|
||||||
import dropdown_menu from './dropdown-menu';
|
import dropdown_menu from './dropdown-menu';
|
||||||
import filters from './filters';
|
import filters from './filters';
|
||||||
|
import group_editor from './group-editor';
|
||||||
|
import group_memberships from './group-memberships';
|
||||||
|
import group_relationships from './group-relationships';
|
||||||
|
import groups from './groups';
|
||||||
import history from './history';
|
import history from './history';
|
||||||
import instance from './instance';
|
import instance from './instance';
|
||||||
import listAdder from './list-adder';
|
import listAdder from './list-adder';
|
||||||
|
@ -120,6 +124,10 @@ const reducers = {
|
||||||
announcements,
|
announcements,
|
||||||
compose_event,
|
compose_event,
|
||||||
admin_user_index,
|
admin_user_index,
|
||||||
|
groups,
|
||||||
|
group_relationships,
|
||||||
|
group_memberships,
|
||||||
|
group_editor,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build a default state from all reducers: it has the key and `undefined`
|
// Build a default state from all reducers: it has the key and `undefined`
|
||||||
|
|
|
@ -11,9 +11,9 @@ import {
|
||||||
import { normalizeList } from 'soapbox/normalizers';
|
import { normalizeList } from 'soapbox/normalizers';
|
||||||
|
|
||||||
import type { AnyAction } from 'redux';
|
import type { AnyAction } from 'redux';
|
||||||
|
import type { APIEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
type ListRecord = ReturnType<typeof normalizeList>;
|
type ListRecord = ReturnType<typeof normalizeList>;
|
||||||
type APIEntity = Record<string, any>;
|
|
||||||
type APIEntities = Array<APIEntity>;
|
type APIEntities = Array<APIEntity>;
|
||||||
|
|
||||||
type State = ImmutableMap<string, ListRecord | false>;
|
type State = ImmutableMap<string, ListRecord | false>;
|
||||||
|
|
|
@ -33,10 +33,10 @@ import {
|
||||||
} from '../actions/importer';
|
} from '../actions/importer';
|
||||||
|
|
||||||
import type { AnyAction } from 'redux';
|
import type { AnyAction } from 'redux';
|
||||||
|
import type { APIEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
type Relationship = ReturnType<typeof normalizeRelationship>;
|
type Relationship = ReturnType<typeof normalizeRelationship>;
|
||||||
type State = ImmutableMap<string, Relationship>;
|
type State = ImmutableMap<string, Relationship>;
|
||||||
type APIEntity = Record<string, any>;
|
|
||||||
type APIEntities = Array<APIEntity>;
|
type APIEntities = Array<APIEntity>;
|
||||||
|
|
||||||
const normalizeRelationships = (state: State, relationships: APIEntities) => {
|
const normalizeRelationships = (state: State, relationships: APIEntities) => {
|
||||||
|
|
|
@ -27,12 +27,15 @@ import type { APIEntity, Tag } from 'soapbox/types/entities';
|
||||||
const ResultsRecord = ImmutableRecord({
|
const ResultsRecord = ImmutableRecord({
|
||||||
accounts: ImmutableOrderedSet<string>(),
|
accounts: ImmutableOrderedSet<string>(),
|
||||||
statuses: ImmutableOrderedSet<string>(),
|
statuses: ImmutableOrderedSet<string>(),
|
||||||
|
groups: ImmutableOrderedSet<string>(),
|
||||||
hashtags: ImmutableOrderedSet<Tag>(), // it's a list of maps
|
hashtags: ImmutableOrderedSet<Tag>(), // it's a list of maps
|
||||||
accountsHasMore: false,
|
accountsHasMore: false,
|
||||||
statusesHasMore: false,
|
statusesHasMore: false,
|
||||||
|
groupsHasMore: false,
|
||||||
hashtagsHasMore: false,
|
hashtagsHasMore: false,
|
||||||
accountsLoaded: false,
|
accountsLoaded: false,
|
||||||
statusesLoaded: false,
|
statusesLoaded: false,
|
||||||
|
groupsLoaded: false,
|
||||||
hashtagsLoaded: false,
|
hashtagsLoaded: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -48,9 +51,9 @@ const ReducerRecord = ImmutableRecord({
|
||||||
|
|
||||||
type State = ReturnType<typeof ReducerRecord>;
|
type State = ReturnType<typeof ReducerRecord>;
|
||||||
type APIEntities = Array<APIEntity>;
|
type APIEntities = Array<APIEntity>;
|
||||||
export type SearchFilter = 'accounts' | 'statuses' | 'hashtags';
|
export type SearchFilter = 'accounts' | 'statuses' | 'groups' | 'hashtags';
|
||||||
|
|
||||||
const toIds = (items: APIEntities) => {
|
const toIds = (items: APIEntities = []) => {
|
||||||
return ImmutableOrderedSet(items.map(item => item.id));
|
return ImmutableOrderedSet(items.map(item => item.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -60,12 +63,15 @@ const importResults = (state: State, results: APIEntity, searchTerm: string, sea
|
||||||
state.set('results', ResultsRecord({
|
state.set('results', ResultsRecord({
|
||||||
accounts: toIds(results.accounts),
|
accounts: toIds(results.accounts),
|
||||||
statuses: toIds(results.statuses),
|
statuses: toIds(results.statuses),
|
||||||
|
groups: toIds(results.groups),
|
||||||
hashtags: ImmutableOrderedSet(results.hashtags.map(normalizeTag)), // it's a list of records
|
hashtags: ImmutableOrderedSet(results.hashtags.map(normalizeTag)), // it's a list of records
|
||||||
accountsHasMore: results.accounts.length >= 20,
|
accountsHasMore: results.accounts.length >= 20,
|
||||||
statusesHasMore: results.statuses.length >= 20,
|
statusesHasMore: results.statuses.length >= 20,
|
||||||
|
groupsHasMore: results.groups?.length >= 20,
|
||||||
hashtagsHasMore: results.hashtags.length >= 20,
|
hashtagsHasMore: results.hashtags.length >= 20,
|
||||||
accountsLoaded: true,
|
accountsLoaded: true,
|
||||||
statusesLoaded: true,
|
statusesLoaded: true,
|
||||||
|
groupsLoaded: true,
|
||||||
hashtagsLoaded: true,
|
hashtagsLoaded: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
@ -42,11 +42,11 @@ import {
|
||||||
import { TIMELINE_DELETE } from '../actions/timelines';
|
import { TIMELINE_DELETE } from '../actions/timelines';
|
||||||
|
|
||||||
import type { AnyAction } from 'redux';
|
import type { AnyAction } from 'redux';
|
||||||
|
import type { APIEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
const domParser = new DOMParser();
|
const domParser = new DOMParser();
|
||||||
|
|
||||||
type StatusRecord = ReturnType<typeof normalizeStatus>;
|
type StatusRecord = ReturnType<typeof normalizeStatus>;
|
||||||
type APIEntity = Record<string, any>;
|
|
||||||
type APIEntities = Array<APIEntity>;
|
type APIEntities = Array<APIEntity>;
|
||||||
|
|
||||||
type State = ImmutableMap<string, ReducerStatus>;
|
type State = ImmutableMap<string, ReducerStatus>;
|
||||||
|
@ -56,6 +56,7 @@ export interface ReducerStatus extends StatusRecord {
|
||||||
reblog: string | null,
|
reblog: string | null,
|
||||||
poll: string | null,
|
poll: string | null,
|
||||||
quote: string | null,
|
quote: string | null,
|
||||||
|
group: string | null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const minifyStatus = (status: StatusRecord): ReducerStatus => {
|
const minifyStatus = (status: StatusRecord): ReducerStatus => {
|
||||||
|
@ -64,6 +65,7 @@ const minifyStatus = (status: StatusRecord): ReducerStatus => {
|
||||||
reblog: normalizeId(status.getIn(['reblog', 'id'])),
|
reblog: normalizeId(status.getIn(['reblog', 'id'])),
|
||||||
poll: normalizeId(status.getIn(['poll', 'id'])),
|
poll: normalizeId(status.getIn(['poll', 'id'])),
|
||||||
quote: normalizeId(status.getIn(['quote', 'id'])),
|
quote: normalizeId(status.getIn(['quote', 'id'])),
|
||||||
|
group: normalizeId(status.getIn(['group', 'id'])),
|
||||||
}) as ReducerStatus;
|
}) as ReducerStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -35,7 +35,6 @@ import {
|
||||||
} from '../actions/timelines';
|
} from '../actions/timelines';
|
||||||
|
|
||||||
import type { AnyAction } from 'redux';
|
import type { AnyAction } from 'redux';
|
||||||
import type { StatusVisibility } from 'soapbox/normalizers/status';
|
|
||||||
import type { APIEntity, Status } from 'soapbox/types/entities';
|
import type { APIEntity, Status } from 'soapbox/types/entities';
|
||||||
|
|
||||||
const TRUNCATE_LIMIT = 40;
|
const TRUNCATE_LIMIT = 40;
|
||||||
|
@ -242,8 +241,10 @@ const timelineDisconnect = (state: State, timelineId: string) => {
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTimelinesByVisibility = (visibility: StatusVisibility) => {
|
const getTimelinesForStatus = (status: APIEntity) => {
|
||||||
switch (visibility) {
|
switch (status.visibility) {
|
||||||
|
case 'group':
|
||||||
|
return [`group:${status.group?.id || status.group_id}`];
|
||||||
case 'direct':
|
case 'direct':
|
||||||
return ['direct'];
|
return ['direct'];
|
||||||
case 'public':
|
case 'public':
|
||||||
|
@ -269,7 +270,7 @@ const importPendingStatus = (state: State, params: APIEntity, idempotencyKey: st
|
||||||
const statusId = `末pending-${idempotencyKey}`;
|
const statusId = `末pending-${idempotencyKey}`;
|
||||||
|
|
||||||
return state.withMutations(state => {
|
return state.withMutations(state => {
|
||||||
const timelineIds = getTimelinesByVisibility(params.visibility);
|
const timelineIds = getTimelinesForStatus(params);
|
||||||
|
|
||||||
timelineIds.forEach(timelineId => {
|
timelineIds.forEach(timelineId => {
|
||||||
updateTimelineQueue(state, timelineId, statusId);
|
updateTimelineQueue(state, timelineId, statusId);
|
||||||
|
@ -293,7 +294,7 @@ const importStatus = (state: State, status: APIEntity, idempotencyKey: string) =
|
||||||
return state.withMutations(state => {
|
return state.withMutations(state => {
|
||||||
replacePendingStatus(state, idempotencyKey, status.id);
|
replacePendingStatus(state, idempotencyKey, status.id);
|
||||||
|
|
||||||
const timelineIds = getTimelinesByVisibility(status.visibility);
|
const timelineIds = getTimelinesForStatus(status);
|
||||||
|
|
||||||
timelineIds.forEach(timelineId => {
|
timelineIds.forEach(timelineId => {
|
||||||
updateTimeline(state, timelineId, status.id);
|
updateTimeline(state, timelineId, status.id);
|
||||||
|
|
|
@ -40,6 +40,23 @@ import {
|
||||||
import {
|
import {
|
||||||
FAMILIAR_FOLLOWERS_FETCH_SUCCESS,
|
FAMILIAR_FOLLOWERS_FETCH_SUCCESS,
|
||||||
} from 'soapbox/actions/familiar-followers';
|
} from 'soapbox/actions/familiar-followers';
|
||||||
|
import {
|
||||||
|
GROUP_MEMBERSHIP_REQUESTS_FETCH_SUCCESS,
|
||||||
|
GROUP_MEMBERSHIP_REQUESTS_EXPAND_SUCCESS,
|
||||||
|
GROUP_MEMBERSHIP_REQUESTS_FETCH_REQUEST,
|
||||||
|
GROUP_MEMBERSHIP_REQUESTS_EXPAND_REQUEST,
|
||||||
|
GROUP_MEMBERSHIP_REQUESTS_FETCH_FAIL,
|
||||||
|
GROUP_MEMBERSHIP_REQUESTS_EXPAND_FAIL,
|
||||||
|
GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_SUCCESS,
|
||||||
|
GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS,
|
||||||
|
GROUP_BLOCKS_FETCH_REQUEST,
|
||||||
|
GROUP_BLOCKS_FETCH_SUCCESS,
|
||||||
|
GROUP_BLOCKS_FETCH_FAIL,
|
||||||
|
GROUP_BLOCKS_EXPAND_REQUEST,
|
||||||
|
GROUP_BLOCKS_EXPAND_SUCCESS,
|
||||||
|
GROUP_BLOCKS_EXPAND_FAIL,
|
||||||
|
GROUP_UNBLOCK_SUCCESS,
|
||||||
|
} from 'soapbox/actions/groups';
|
||||||
import {
|
import {
|
||||||
REBLOGS_FETCH_SUCCESS,
|
REBLOGS_FETCH_SUCCESS,
|
||||||
FAVOURITES_FETCH_SUCCESS,
|
FAVOURITES_FETCH_SUCCESS,
|
||||||
|
@ -99,6 +116,8 @@ export const ReducerRecord = ImmutableRecord({
|
||||||
familiar_followers: ImmutableMap<string, List>(),
|
familiar_followers: ImmutableMap<string, List>(),
|
||||||
event_participations: ImmutableMap<string, List>(),
|
event_participations: ImmutableMap<string, List>(),
|
||||||
event_participation_requests: ImmutableMap<string, ParticipationRequestList>(),
|
event_participation_requests: ImmutableMap<string, ParticipationRequestList>(),
|
||||||
|
membership_requests: ImmutableMap<string, List>(),
|
||||||
|
group_blocks: ImmutableMap<string, List>(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type State = ReturnType<typeof ReducerRecord>;
|
type State = ReturnType<typeof ReducerRecord>;
|
||||||
|
@ -108,7 +127,7 @@ type ReactionList = ReturnType<typeof ReactionListRecord>;
|
||||||
type ParticipationRequest = ReturnType<typeof ParticipationRequestRecord>;
|
type ParticipationRequest = ReturnType<typeof ParticipationRequestRecord>;
|
||||||
type ParticipationRequestList = ReturnType<typeof ParticipationRequestListRecord>;
|
type ParticipationRequestList = ReturnType<typeof ParticipationRequestListRecord>;
|
||||||
type Items = ImmutableOrderedSet<string>;
|
type Items = ImmutableOrderedSet<string>;
|
||||||
type NestedListPath = ['followers' | 'following' | 'reblogged_by' | 'favourited_by' | 'reactions' | 'pinned' | 'birthday_reminders' | 'familiar_followers' | 'event_participations' | 'event_participation_requests', string];
|
type NestedListPath = ['followers' | 'following' | 'reblogged_by' | 'favourited_by' | 'reactions' | 'pinned' | 'birthday_reminders' | 'familiar_followers' | 'event_participations' | 'event_participation_requests' | 'membership_requests' | 'group_blocks', string];
|
||||||
type ListPath = ['follow_requests' | 'blocks' | 'mutes' | 'directory'];
|
type ListPath = ['follow_requests' | 'blocks' | 'mutes' | 'directory'];
|
||||||
|
|
||||||
const normalizeList = (state: State, path: NestedListPath | ListPath, accounts: APIEntity[], next?: string | null) => {
|
const normalizeList = (state: State, path: NestedListPath | ListPath, accounts: APIEntity[], next?: string | null) => {
|
||||||
|
@ -220,6 +239,31 @@ export default function userLists(state = ReducerRecord(), action: AnyAction) {
|
||||||
['event_participation_requests', action.id, 'items'],
|
['event_participation_requests', action.id, 'items'],
|
||||||
items => (items as ImmutableOrderedSet<ParticipationRequest>).filter(({ account }) => account !== action.accountId),
|
items => (items as ImmutableOrderedSet<ParticipationRequest>).filter(({ account }) => account !== action.accountId),
|
||||||
);
|
);
|
||||||
|
case GROUP_MEMBERSHIP_REQUESTS_FETCH_SUCCESS:
|
||||||
|
return normalizeList(state, ['membership_requests', action.id], action.accounts, action.next);
|
||||||
|
case GROUP_MEMBERSHIP_REQUESTS_EXPAND_SUCCESS:
|
||||||
|
return appendToList(state, ['membership_requests', action.id], action.accounts, action.next);
|
||||||
|
case GROUP_MEMBERSHIP_REQUESTS_FETCH_REQUEST:
|
||||||
|
case GROUP_MEMBERSHIP_REQUESTS_EXPAND_REQUEST:
|
||||||
|
return state.setIn(['membership_requests', action.id, 'isLoading'], true);
|
||||||
|
case GROUP_MEMBERSHIP_REQUESTS_FETCH_FAIL:
|
||||||
|
case GROUP_MEMBERSHIP_REQUESTS_EXPAND_FAIL:
|
||||||
|
return state.setIn(['membership_requests', action.id, 'isLoading'], false);
|
||||||
|
case GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_SUCCESS:
|
||||||
|
case GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS:
|
||||||
|
return state.updateIn(['membership_requests', action.groupId, 'items'], list => (list as ImmutableOrderedSet<string>).filterNot(item => item === action.accountId));
|
||||||
|
case GROUP_BLOCKS_FETCH_SUCCESS:
|
||||||
|
return normalizeList(state, ['group_blocks', action.id], action.accounts, action.next);
|
||||||
|
case GROUP_BLOCKS_EXPAND_SUCCESS:
|
||||||
|
return appendToList(state, ['group_blocks', action.id], action.accounts, action.next);
|
||||||
|
case GROUP_BLOCKS_FETCH_REQUEST:
|
||||||
|
case GROUP_BLOCKS_EXPAND_REQUEST:
|
||||||
|
return state.setIn(['group_blocks', action.id, 'isLoading'], true);
|
||||||
|
case GROUP_BLOCKS_FETCH_FAIL:
|
||||||
|
case GROUP_BLOCKS_EXPAND_FAIL:
|
||||||
|
return state.setIn(['group_blocks', action.id, 'isLoading'], false);
|
||||||
|
case GROUP_UNBLOCK_SUCCESS:
|
||||||
|
return state.updateIn(['group_blocks', action.groupId, 'items'], list => (list as ImmutableOrderedSet<string>).filterNot(item => item === action.accountId));
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -144,12 +144,13 @@ export const makeGetStatus = () => {
|
||||||
(state: RootState, { id }: APIStatus) => state.statuses.get(state.statuses.get(id)?.reblog || ''),
|
(state: RootState, { id }: APIStatus) => state.statuses.get(state.statuses.get(id)?.reblog || ''),
|
||||||
(state: RootState, { id }: APIStatus) => state.accounts.get(state.statuses.get(id)?.account || ''),
|
(state: RootState, { id }: APIStatus) => state.accounts.get(state.statuses.get(id)?.account || ''),
|
||||||
(state: RootState, { id }: APIStatus) => state.accounts.get(state.statuses.get(state.statuses.get(id)?.reblog || '')?.account || ''),
|
(state: RootState, { id }: APIStatus) => state.accounts.get(state.statuses.get(state.statuses.get(id)?.reblog || '')?.account || ''),
|
||||||
|
(state: RootState, { id }: APIStatus) => state.groups.items.get(state.statuses.get(id)?.group || ''),
|
||||||
(_state: RootState, { username }: APIStatus) => username,
|
(_state: RootState, { username }: APIStatus) => username,
|
||||||
getFilters,
|
getFilters,
|
||||||
(state: RootState) => state.me,
|
(state: RootState) => state.me,
|
||||||
],
|
],
|
||||||
|
|
||||||
(statusBase, statusReblog, accountBase, accountReblog, username, filters, me) => {
|
(statusBase, statusReblog, accountBase, accountReblog, group, username, filters, me) => {
|
||||||
if (!statusBase || !accountBase) return null;
|
if (!statusBase || !accountBase) return null;
|
||||||
|
|
||||||
const accountUsername = accountBase.acct;
|
const accountUsername = accountBase.acct;
|
||||||
|
@ -172,6 +173,8 @@ export const makeGetStatus = () => {
|
||||||
map.set('reblog', statusReblog || null);
|
map.set('reblog', statusReblog || null);
|
||||||
// @ts-ignore :(
|
// @ts-ignore :(
|
||||||
map.set('account', accountBase || null);
|
map.set('account', accountBase || null);
|
||||||
|
// @ts-ignore
|
||||||
|
map.set('group', group || null);
|
||||||
map.set('filtered', Boolean(filtered));
|
map.set('filtered', Boolean(filtered));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
@ -215,6 +218,25 @@ export const getAccountGallery = createSelector([
|
||||||
}, ImmutableList());
|
}, ImmutableList());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getGroupGallery = createSelector([
|
||||||
|
(state: RootState, id: string) => state.timelines.get(`group:${id}:media`)?.items || ImmutableOrderedSet<string>(),
|
||||||
|
(state: RootState) => state.statuses,
|
||||||
|
(state: RootState) => state.accounts,
|
||||||
|
], (statusIds, statuses, accounts) => {
|
||||||
|
|
||||||
|
return statusIds.reduce((medias: ImmutableList<any>, statusId: string) => {
|
||||||
|
const status = statuses.get(statusId);
|
||||||
|
if (!status) return medias;
|
||||||
|
if (status.reblog) return medias;
|
||||||
|
if (typeof status.account !== 'string') return medias;
|
||||||
|
|
||||||
|
const account = accounts.get(status.account);
|
||||||
|
|
||||||
|
return medias.concat(
|
||||||
|
status.media_attachments.map(media => media.merge({ status, account })));
|
||||||
|
}, ImmutableList());
|
||||||
|
});
|
||||||
|
|
||||||
type APIChat = { id: string, last_message: string };
|
type APIChat = { id: string, last_message: string };
|
||||||
|
|
||||||
export const makeGetChat = () => {
|
export const makeGetChat = () => {
|
||||||
|
@ -350,3 +372,16 @@ export const makeGetStatusIds = () => createSelector([
|
||||||
return !shouldFilter(status, columnSettings);
|
return !shouldFilter(status, columnSettings);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const makeGetGroup = () => {
|
||||||
|
return createSelector([
|
||||||
|
(state: RootState, id: string) => state.groups.items.get(id),
|
||||||
|
(state: RootState, id: string) => state.group_relationships.get(id),
|
||||||
|
], (base, relationship) => {
|
||||||
|
if (!base) return null;
|
||||||
|
|
||||||
|
return base.withMutations(map => {
|
||||||
|
if (relationship) map.set('relationship', relationship);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
|
@ -11,6 +11,8 @@ import {
|
||||||
EmojiRecord,
|
EmojiRecord,
|
||||||
FieldRecord,
|
FieldRecord,
|
||||||
FilterRecord,
|
FilterRecord,
|
||||||
|
GroupRecord,
|
||||||
|
GroupRelationshipRecord,
|
||||||
HistoryRecord,
|
HistoryRecord,
|
||||||
InstanceRecord,
|
InstanceRecord,
|
||||||
ListRecord,
|
ListRecord,
|
||||||
|
@ -40,6 +42,8 @@ type ChatMessage = ReturnType<typeof ChatMessageRecord>;
|
||||||
type Emoji = ReturnType<typeof EmojiRecord>;
|
type Emoji = ReturnType<typeof EmojiRecord>;
|
||||||
type Field = ReturnType<typeof FieldRecord>;
|
type Field = ReturnType<typeof FieldRecord>;
|
||||||
type Filter = ReturnType<typeof FilterRecord>;
|
type Filter = ReturnType<typeof FilterRecord>;
|
||||||
|
type Group = ReturnType<typeof GroupRecord>;
|
||||||
|
type GroupRelationship = ReturnType<typeof GroupRelationshipRecord>;
|
||||||
type History = ReturnType<typeof HistoryRecord>;
|
type History = ReturnType<typeof HistoryRecord>;
|
||||||
type Instance = ReturnType<typeof InstanceRecord>;
|
type Instance = ReturnType<typeof InstanceRecord>;
|
||||||
type List = ReturnType<typeof ListRecord>;
|
type List = ReturnType<typeof ListRecord>;
|
||||||
|
@ -82,6 +86,8 @@ export {
|
||||||
Emoji,
|
Emoji,
|
||||||
Field,
|
Field,
|
||||||
Filter,
|
Filter,
|
||||||
|
Group,
|
||||||
|
GroupRelationship,
|
||||||
History,
|
History,
|
||||||
Instance,
|
Instance,
|
||||||
List,
|
List,
|
||||||
|
|
|
@ -463,6 +463,34 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||||
*/
|
*/
|
||||||
frontendConfigurations: v.software === PLEROMA,
|
frontendConfigurations: v.software === PLEROMA,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups.
|
||||||
|
* @see POST /api/v1/groups
|
||||||
|
* @see GET /api/v1/groups
|
||||||
|
* @see GET /api/v1/groups/:id
|
||||||
|
* @see POST /api/v1/groups/:id/join
|
||||||
|
* @see POST /api/v1/groups/:id/leave
|
||||||
|
* @see GET /api/v1/groups/:id/memberships
|
||||||
|
* @see PUT /api/v1/groups/:group_id
|
||||||
|
* @see DELETE /api/v1/groups/:group_id
|
||||||
|
* @see GET /api/v1/groups/:group_id/membership_requests
|
||||||
|
* @see POST /api/v1/groups/:group_id/membership_requests/:account_id/authorize
|
||||||
|
* @see POST /api/v1/groups/:group_id/membership_requests/:account_id/reject
|
||||||
|
* @see DELETE /api/v1/groups/:group_id/statuses/:id
|
||||||
|
* @see POST /api/v1/groups/:group_id/kick?account_ids[]=…
|
||||||
|
* @see GET /api/v1/groups/:group_id/blocks
|
||||||
|
* @see POST /api/v1/groups/:group_id/blocks?account_ids[]=…
|
||||||
|
* @see DELETE /api/v1/groups/:group_id/blocks?account_ids[]=…
|
||||||
|
* @see POST /api/v1/groups/:group_id/promote?role=new_role&account_ids[]=…
|
||||||
|
* @see POST /api/v1/groups/:group_id/demote?role=new_role&account_ids[]=…
|
||||||
|
* @see GET /api/v1/admin/groups
|
||||||
|
* @see GET /api/v1/admin/groups/:group_id
|
||||||
|
* @see POST /api/v1/admin/groups/:group_id/suspend
|
||||||
|
* @see POST /api/v1/admin/groups/:group_id/unsuspend
|
||||||
|
* @see DELETE /api/v1/admin/groups/:group_id
|
||||||
|
*/
|
||||||
|
groups: false,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Can hide follows/followers lists and counts.
|
* Can hide follows/followers lists and counts.
|
||||||
* @see PATCH /api/v1/accounts/update_credentials
|
* @see PATCH /api/v1/accounts/update_credentials
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { RootState } from 'soapbox/store';
|
||||||
|
|
||||||
|
export const PERMISSION_CREATE_GROUPS = 0x0000000000100000;
|
||||||
|
export const PERMISSION_INVITE_USERS = 0x0000000000010000;
|
||||||
|
export const PERMISSION_MANAGE_USERS = 0x0000000000000400;
|
||||||
|
export const PERMISSION_MANAGE_REPORTS = 0x0000000000000010;
|
||||||
|
|
||||||
|
type Permission = typeof PERMISSION_CREATE_GROUPS | typeof PERMISSION_INVITE_USERS | typeof PERMISSION_MANAGE_USERS | typeof PERMISSION_MANAGE_REPORTS
|
||||||
|
|
||||||
|
export const hasPermission = (state: RootState, permission: Permission) => {
|
||||||
|
const account = state.accounts_meta.get(state.me as string)!;
|
||||||
|
|
||||||
|
if (!account?.role) return false;
|
||||||
|
|
||||||
|
const permissions = account.getIn(['role', 'permissions']) as number;
|
||||||
|
|
||||||
|
if (!permission) return true;
|
||||||
|
return (permissions & permission) === permission;
|
||||||
|
};
|
|
@ -69,12 +69,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.account__joined-at {
|
.account__joined-at {
|
||||||
|
@apply text-gray-400;
|
||||||
padding-left: 3px;
|
padding-left: 3px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
display: flex;
|
display: flex;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--primary-text-color--faint);
|
|
||||||
|
|
||||||
.svg-icon {
|
.svg-icon {
|
||||||
padding-right: 3px;
|
padding-right: 3px;
|
||||||
|
|
|
@ -39,9 +39,9 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
&.error {
|
&.error {
|
||||||
|
@apply text-gray-400;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--primary-text-color--faint);
|
|
||||||
background: var(--brand-color--med);
|
background: var(--brand-color--med);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
@ -83,9 +83,9 @@ noscript {
|
||||||
}
|
}
|
||||||
|
|
||||||
div {
|
div {
|
||||||
|
@apply text-gray-400;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin: 30px auto;
|
margin: 30px auto;
|
||||||
color: var(--primary-text-color--faint);
|
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
|
|
@ -25,8 +25,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.column-link {
|
.column-link {
|
||||||
|
@apply text-gray-900;
|
||||||
background: var(--brand-color--med);
|
background: var(--brand-color--med);
|
||||||
color: var(--primary-text-color);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
|
|
@ -26,9 +26,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&__modifiers {
|
&__modifiers {
|
||||||
color: var(--primary-text-color);
|
@apply text-gray-900 text-sm;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 14px;
|
|
||||||
background: var(--background-color);
|
background: var(--background-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -184,7 +183,7 @@
|
||||||
background: var(--brand-color);
|
background: var(--brand-color);
|
||||||
|
|
||||||
.icon-button {
|
.icon-button {
|
||||||
color: var(--primary-text-color);
|
@apply text-gray-900;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,64 +1,47 @@
|
||||||
.crypto-address {
|
.crypto-address {
|
||||||
padding: 20px;
|
@apply flex flex-col p-5;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
&__head {
|
&__head {
|
||||||
display: flex;
|
@apply flex items-center mb-1.5;
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
font-weight: bold;
|
@apply font-bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__icon {
|
&__icon {
|
||||||
display: flex;
|
@apply flex items-start justify-center w-6 mr-2.5;
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: center;
|
|
||||||
width: 24px;
|
|
||||||
margin-right: 10px;
|
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 100%;
|
@apply w-full;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__actions {
|
&__actions {
|
||||||
margin-left: auto;
|
@apply flex ml-auto;
|
||||||
display: flex;
|
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--primary-text-color--faint);
|
@apply text-gray-400 ml-2;
|
||||||
margin-left: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.svg-icon {
|
.svg-icon {
|
||||||
width: 18px;
|
@apply h-4.5 w-4.5;
|
||||||
height: 18px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__note {
|
&__note {
|
||||||
margin-bottom: 10px;
|
@apply mb-2.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__qrcode {
|
&__qrcode {
|
||||||
margin-bottom: 12px;
|
@apply flex items-center justify-center mb-3 p-2.5;
|
||||||
padding: 10px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__address {
|
&__address {
|
||||||
margin-top: auto;
|
@apply mt-auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.crypto-donate-modal {
|
.crypto-donate-modal .crypto-address {
|
||||||
.crypto-address {
|
@apply p-0;
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -51,10 +51,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-gallery__item-thumbnail {
|
.media-gallery__item-thumbnail {
|
||||||
|
@apply text-gray-400;
|
||||||
cursor: zoom-in;
|
cursor: zoom-in;
|
||||||
display: block;
|
display: block;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--primary-text-color--faint);
|
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|
|
@ -165,8 +165,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-modal {
|
.error-modal {
|
||||||
|
@apply text-gray-900;
|
||||||
background: var(--background-color);
|
background: var(--background-color);
|
||||||
color: var(--primary-text-color);
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -231,19 +231,16 @@
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus,
|
&:focus,
|
||||||
&:active {
|
&:active {
|
||||||
color: var(--primary-text-color--faint);
|
@apply text-gray-400;
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions-modal {
|
.actions-modal {
|
||||||
position: relative;
|
@apply flex-col relative text-gray-400 overflow-hidden;
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid var(--background-color);
|
border: 1px solid var(--background-color);
|
||||||
color: var(--primary-text-color--faint);
|
|
||||||
|
|
||||||
.dropdown-menu__separator {
|
.dropdown-menu__separator {
|
||||||
@apply block m-2 h-[1px] bg-gray-200 dark:bg-gray-600;
|
@apply block m-2 h-[1px] bg-gray-200 dark:bg-gray-600;
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.svg-icon {
|
.svg-icon {
|
||||||
@apply right-4 rtl:left-4 rtl:right-auto;
|
@apply right-4 rtl:left-4 rtl:right-auto text-gray-400;
|
||||||
@include font-size(16);
|
@include font-size(16);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
@ -23,7 +23,6 @@
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
color: var(--primary-text-color--faint);
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
|
@ -51,9 +50,9 @@
|
||||||
|
|
||||||
.column {
|
.column {
|
||||||
.search {
|
.search {
|
||||||
|
@apply border border-solid border-b-gray-900/20;
|
||||||
padding: 10px 15px;
|
padding: 10px 15px;
|
||||||
background-color: var(--foreground-color);
|
background-color: var(--foreground-color);
|
||||||
border-bottom: 1px solid hsla(var(--primary-text-color_hsl), 0.2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search__icon .svg-icon {
|
.search__icon .svg-icon {
|
||||||
|
|
|
@ -68,7 +68,7 @@
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
height: 270px;
|
height: 270px;
|
||||||
padding: 0 6px 6px 6px;
|
padding: 0 6px 6px;
|
||||||
will-change: transform; /* avoids "repaints on scroll" in mobile Chrome */
|
will-change: transform; /* avoids "repaints on scroll" in mobile Chrome */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -101,7 +101,7 @@
|
||||||
.emoji-mart-search input::-webkit-search-results-decoration {
|
.emoji-mart-search input::-webkit-search-results-decoration {
|
||||||
/* remove webkit/blink styles for <input type="search">
|
/* remove webkit/blink styles for <input type="search">
|
||||||
* via https://stackoverflow.com/a/9422689 */
|
* via https://stackoverflow.com/a/9422689 */
|
||||||
-webkit-appearance: none;
|
appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emoji-mart-search-icon {
|
.emoji-mart-search-icon {
|
||||||
|
@ -127,7 +127,7 @@
|
||||||
.emoji-mart-category .emoji-mart-emoji:hover::before {
|
.emoji-mart-category .emoji-mart-emoji:hover::before {
|
||||||
@apply bg-gray-50 dark:bg-primary-800;
|
@apply bg-gray-50 dark:bg-primary-800;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
content: "";
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
@ -139,7 +139,6 @@
|
||||||
.emoji-mart-category-label {
|
.emoji-mart-category-label {
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
position: relative;
|
position: relative;
|
||||||
position: -webkit-sticky;
|
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
}
|
}
|
||||||
|
@ -176,7 +175,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.emoji-mart-emoji-native {
|
.emoji-mart-emoji-native {
|
||||||
font-family: "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji", sans-serif;
|
font-family: 'Segoe UI Emoji', 'Segoe UI Symbol', 'Segoe UI', 'Apple Color Emoji', 'Twemoji Mozilla', 'Noto Color Emoji', 'Android Emoji', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emoji-mart-no-results {
|
.emoji-mart-no-results {
|
||||||
|
|
|
@ -101,7 +101,7 @@ select {
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
color: var(--primary-text-color--faint);
|
@apply text-gray-400;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--highlight-text-color);
|
color: var(--highlight-text-color);
|
||||||
|
@ -120,8 +120,8 @@ select {
|
||||||
}
|
}
|
||||||
|
|
||||||
p.hint {
|
p.hint {
|
||||||
|
@apply text-gray-400;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
color: var(--primary-text-color--faint);
|
|
||||||
|
|
||||||
&.subtle-hint {
|
&.subtle-hint {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
@ -151,9 +151,9 @@ select {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
& > label {
|
& > label {
|
||||||
|
@apply text-gray-900;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--primary-text-color);
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
@ -204,10 +204,10 @@ select {
|
||||||
}
|
}
|
||||||
|
|
||||||
.input.radio_buttons .radio label {
|
.input.radio_buttons .radio label {
|
||||||
|
@apply text-gray-900;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--primary-text-color);
|
|
||||||
display: block;
|
display: block;
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
@ -219,8 +219,7 @@ select {
|
||||||
input[type=url],
|
input[type=url],
|
||||||
input[type=password],
|
input[type=password],
|
||||||
textarea {
|
textarea {
|
||||||
color: var(--primary-text-color--faint);
|
@apply text-gray-400 border-gray-400;
|
||||||
border-color: var(--primary-text-color--faint);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -231,17 +230,15 @@ select {
|
||||||
input[type=password],
|
input[type=password],
|
||||||
textarea,
|
textarea,
|
||||||
.rfipbtn {
|
.rfipbtn {
|
||||||
@apply border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-black dark:text-white;
|
@apply border border-solid border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-black dark:text-white;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: var(--primary-text-color);
|
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
border: 1px solid var(--input-border-color);
|
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
transition: 0.2s;
|
transition: 0.2s;
|
||||||
|
@ -280,8 +277,7 @@ select {
|
||||||
input[type=url][disabled],
|
input[type=url][disabled],
|
||||||
input[type=password][disabled],
|
input[type=password][disabled],
|
||||||
textarea[disabled] {
|
textarea[disabled] {
|
||||||
color: var(--primary-text-color--faint);
|
@apply text-gray-400 border-gray-400;
|
||||||
border-color: var(--primary-text-color--faint);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.input.field_with_errors {
|
.input.field_with_errors {
|
||||||
|
@ -403,6 +399,10 @@ select {
|
||||||
// padding-top: 0.5rem;
|
// padding-top: 0.5rem;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
.label_input__wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
line-height: normal;
|
line-height: normal;
|
||||||
|
@ -413,9 +413,9 @@ select {
|
||||||
|
|
||||||
.simple_form {
|
.simple_form {
|
||||||
.warning {
|
.warning {
|
||||||
|
@apply text-gray-900;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: rgba($error-value-color, 0.5);
|
background: rgba($error-value-color, 0.5);
|
||||||
color: var(--primary-text-color);
|
|
||||||
text-shadow: 1px 1px 0 rgba($base-shadow-color, 0.3);
|
text-shadow: 1px 1px 0 rgba($base-shadow-color, 0.3);
|
||||||
box-shadow: 0 2px 6px rgba($base-shadow-color, 0.4);
|
box-shadow: 0 2px 6px rgba($base-shadow-color, 0.4);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
@ -423,13 +423,12 @@ select {
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--primary-text-color);
|
@apply text-gray-900 underline;
|
||||||
text-decoration: underline;
|
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus,
|
&:focus,
|
||||||
&:active {
|
&:active {
|
||||||
text-decoration: none;
|
@apply no-underline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -128,14 +128,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.load-more {
|
.load-more {
|
||||||
display: block;
|
@apply block w-full m-0 p-4 border-0 box-border text-gray-900 bg-transparent;
|
||||||
color: var(--primary-text-color);
|
|
||||||
background-color: transparent;
|
|
||||||
border: 0;
|
|
||||||
margin: 0;
|
|
||||||
padding: 15px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus {
|
&:focus {
|
||||||
|
@ -152,10 +145,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.regeneration-indicator {
|
.regeneration-indicator {
|
||||||
|
@apply text-gray-900;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--primary-text-color);
|
|
||||||
background: var(--accent-color--faint);
|
background: var(--accent-color--faint);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -177,9 +170,7 @@
|
||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
strong {
|
strong {
|
||||||
display: block;
|
@apply block mb-2.5 text-gray-900;
|
||||||
margin-bottom: 10px;
|
|
||||||
color: var(--primary-text-color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
|
|
|
@ -29,18 +29,14 @@ body,
|
||||||
// Primary variables
|
// Primary variables
|
||||||
--brand-color: hsl(var(--brand-color_hsl));
|
--brand-color: hsl(var(--brand-color_hsl));
|
||||||
--accent-color: hsl(var(--accent-color_hsl));
|
--accent-color: hsl(var(--accent-color_hsl));
|
||||||
--primary-text-color: var(--gray-900);
|
|
||||||
--background-color: hsl(var(--background-color_hsl));
|
--background-color: hsl(var(--background-color_hsl));
|
||||||
--foreground-color: hsl(var(--foreground-color_hsl));
|
--foreground-color: hsl(var(--foreground-color_hsl));
|
||||||
--warning-color: hsla(var(--warning-color_hsl));
|
|
||||||
|
|
||||||
// Meta-variables
|
// Meta-variables
|
||||||
--brand-color_hsl: var(--brand-color_h), var(--brand-color_s), var(--brand-color_l);
|
--brand-color_hsl: var(--brand-color_h), var(--brand-color_s), var(--brand-color_l);
|
||||||
--accent-color_hsl: var(--accent-color_h), var(--accent-color_s), var(--accent-color_l);
|
--accent-color_hsl: var(--accent-color_h), var(--accent-color_s), var(--accent-color_l);
|
||||||
--primary-text-color_hsl: var(--primary-text-color_h), var(--primary-text-color_s), var(--primary-text-color_l);
|
|
||||||
--background-color_hsl: var(--background-color_h), var(--background-color_s), var(--background-color_l);
|
--background-color_hsl: var(--background-color_h), var(--background-color_s), var(--background-color_l);
|
||||||
--foreground-color_hsl: var(--foreground-color_h), var(--foreground-color_s), var(--foreground-color_l);
|
--foreground-color_hsl: var(--foreground-color_h), var(--foreground-color_s), var(--foreground-color_l);
|
||||||
--warning-color_hsl: var(--warning-color_h), var(--warning-color_s), var(--warning-color_l);
|
|
||||||
--accent-color_h: calc(var(--brand-color_h) - 15);
|
--accent-color_h: calc(var(--brand-color_h) - 15);
|
||||||
--accent-color_s: 86%;
|
--accent-color_s: 86%;
|
||||||
--accent-color_l: 44%;
|
--accent-color_l: 44%;
|
||||||
|
@ -49,23 +45,11 @@ body,
|
||||||
--brand-color--faint: hsla(var(--brand-color_hsl), 0.1);
|
--brand-color--faint: hsla(var(--brand-color_hsl), 0.1);
|
||||||
--brand-color--med: hsla(var(--brand-color_hsl), 0.2);
|
--brand-color--med: hsla(var(--brand-color_hsl), 0.2);
|
||||||
--accent-color--faint: hsla(var(--accent-color_hsl), 0.15);
|
--accent-color--faint: hsla(var(--accent-color_hsl), 0.15);
|
||||||
--accent-color--med: hsla(var(--accent-color_hsl), 0.25);
|
|
||||||
--accent-color--bright: hsl(
|
--accent-color--bright: hsl(
|
||||||
var(--accent-color_h),
|
var(--accent-color_h),
|
||||||
var(--accent-color_s),
|
var(--accent-color_s),
|
||||||
calc(var(--accent-color_l) + 3%)
|
calc(var(--accent-color_l) + 3%)
|
||||||
);
|
);
|
||||||
--primary-text-color--faint: var(--gray-400);
|
|
||||||
--warning-color--faint: hsla(var(--warning-color_hsl), 0.5);
|
|
||||||
|
|
||||||
// Colors
|
|
||||||
--gray-900: #08051b;
|
|
||||||
// --gray-800: #1d1932;
|
|
||||||
--gray-500: #656175;
|
|
||||||
--gray-400: #868393;
|
|
||||||
|
|
||||||
// Forms
|
|
||||||
--input-border-color: #d1d5db;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-mode-light {
|
.theme-mode-light {
|
||||||
|
@ -77,18 +61,12 @@ body,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Meta-variables
|
// Meta-variables
|
||||||
--primary-text-color_h: 0;
|
|
||||||
--primary-text-color_s: 0%;
|
|
||||||
--primary-text-color_l: 0%;
|
|
||||||
--background-color_h: 0;
|
--background-color_h: 0;
|
||||||
--background-color_s: 0%;
|
--background-color_s: 0%;
|
||||||
--background-color_l: 94.9%;
|
--background-color_l: 94.9%;
|
||||||
--foreground-color_h: 0;
|
--foreground-color_h: 0;
|
||||||
--foreground-color_s: 0%;
|
--foreground-color_s: 0%;
|
||||||
--foreground-color_l: 100%;
|
--foreground-color_l: 100%;
|
||||||
--warning-color_h: 0;
|
|
||||||
--warning-color_s: 100%;
|
|
||||||
--warning-color_l: 66%;
|
|
||||||
|
|
||||||
// Modifiers
|
// Modifiers
|
||||||
--brand-color--hicontrast: hsl(
|
--brand-color--hicontrast: hsl(
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
color: var(--primary-text-color);
|
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
@ -130,20 +129,13 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-text {
|
.setting-text {
|
||||||
color: var(--primary-text-color--faint);
|
@apply block w-full mb-2.5 border-0 border-b-2 border-solid box-border text-gray-400 bg-transparent;
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
border-bottom: 2px solid var(--brand-color);
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: block;
|
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
margin-bottom: 10px;
|
|
||||||
padding: 7px 0;
|
padding: 7px 0;
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&:focus,
|
&:focus,
|
||||||
&:active {
|
&:active {
|
||||||
color: var(--primary-text-color);
|
@apply text-gray-900;
|
||||||
border-bottom-color: var(--highlight-text-color);
|
border-bottom-color: var(--highlight-text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -172,11 +164,3 @@
|
||||||
30% { opacity: 0.75; }
|
30% { opacity: 0.75; }
|
||||||
100% { opacity: 1; }
|
100% { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-muted {
|
|
||||||
color: var(--gray-500);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mb-10 {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
|
@ -67,7 +67,7 @@
|
||||||
"@sentry/browser": "^7.11.1",
|
"@sentry/browser": "^7.11.1",
|
||||||
"@sentry/react": "^7.11.1",
|
"@sentry/react": "^7.11.1",
|
||||||
"@sentry/tracing": "^7.11.1",
|
"@sentry/tracing": "^7.11.1",
|
||||||
"@tabler/icons": "^1.113.0",
|
"@tabler/icons": "^1.117.0",
|
||||||
"@tailwindcss/forms": "^0.5.3",
|
"@tailwindcss/forms": "^0.5.3",
|
||||||
"@tailwindcss/line-clamp": "^0.4.2",
|
"@tailwindcss/line-clamp": "^0.4.2",
|
||||||
"@tailwindcss/typography": "^0.5.7",
|
"@tailwindcss/typography": "^0.5.7",
|
||||||
|
|
|
@ -2362,10 +2362,10 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
defer-to-connect "^2.0.0"
|
defer-to-connect "^2.0.0"
|
||||||
|
|
||||||
"@tabler/icons@^1.113.0":
|
"@tabler/icons@^1.117.0":
|
||||||
version "1.113.0"
|
version "1.117.0"
|
||||||
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-1.113.0.tgz#aeee5f38284d9996abec1bda46c237ef53cde8d4"
|
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-1.117.0.tgz#2ffafca94f868940cf84a839e284c243e095c45a"
|
||||||
integrity sha512-DjxsvR/0HFHD/utQlM+q3wpl1W2n+jgEZkyfkCkc295rCoAfeXHIBfz/9ROrSHkr205Kq/M8KpQR0Nd4kjwODQ==
|
integrity sha512-4UGF8fMcROiy++CCNlzTz6p22rxFQD/fAMfaw/8Uanopl41X2SCZTmpnotS3C6Qdrk99m8eMZySa5w1y99gFqQ==
|
||||||
|
|
||||||
"@tailwindcss/forms@^0.5.3":
|
"@tailwindcss/forms@^0.5.3":
|
||||||
version "0.5.3"
|
version "0.5.3"
|
||||||
|
|
Loading…
Reference in New Issue