Merge remote-tracking branch 'soapbox/develop' into lexical
This commit is contained in:
commit
2549e72843
|
@ -16,7 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
### Changed
|
||||
- Posts: truncate Nostr pubkeys in reply mentions.
|
||||
- Posts: upgraded emoji picker component.
|
||||
- Posts: improved design of threads.
|
||||
- UI: unified design of "approve" and "reject" buttons in follow requests and waitlist.
|
||||
- UI: added sticky column header.
|
||||
|
||||
### Fixed
|
||||
- Posts: fixed emojis being cut off in reactions modal.
|
||||
|
|
|
@ -139,6 +139,7 @@ const StatusList: React.FC<IStatusList> = ({
|
|||
onMoveDown={handleMoveDown}
|
||||
contextType={timelineId}
|
||||
showGroup={showGroup}
|
||||
variant={divideType === 'border' ? 'slim' : 'rounded'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -172,6 +173,7 @@ const StatusList: React.FC<IStatusList> = ({
|
|||
onMoveDown={handleMoveDown}
|
||||
contextType={timelineId}
|
||||
showGroup={showGroup}
|
||||
variant={divideType === 'border' ? 'slim' : 'default'}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
@ -245,7 +247,7 @@ const StatusList: React.FC<IStatusList> = ({
|
|||
isLoading={isLoading}
|
||||
showLoading={isLoading && statusIds.size === 0}
|
||||
onLoadMore={handleLoadOlder}
|
||||
placeholderComponent={PlaceholderStatus}
|
||||
placeholderComponent={() => <PlaceholderStatus variant={divideType === 'border' ? 'slim' : 'rounded'} />}
|
||||
placeholderCount={20}
|
||||
ref={node}
|
||||
className={clsx('divide-y divide-solid divide-gray-200 dark:divide-gray-800', {
|
||||
|
|
|
@ -50,7 +50,7 @@ export interface IStatus {
|
|||
featured?: boolean
|
||||
hideActionBar?: boolean
|
||||
hoverable?: boolean
|
||||
variant?: 'default' | 'rounded'
|
||||
variant?: 'default' | 'rounded' | 'slim'
|
||||
showGroup?: boolean
|
||||
accountAction?: React.ReactElement
|
||||
}
|
||||
|
|
|
@ -16,11 +16,13 @@ const messages = defineMessages({
|
|||
back: { id: 'card.back.label', defaultMessage: 'Back' },
|
||||
});
|
||||
|
||||
export type CardSizes = keyof typeof sizes
|
||||
|
||||
interface ICard {
|
||||
/** The type of card. */
|
||||
variant?: 'default' | 'rounded'
|
||||
variant?: 'default' | 'rounded' | 'slim'
|
||||
/** Card size preset. */
|
||||
size?: keyof typeof sizes
|
||||
size?: CardSizes
|
||||
/** Extra classnames for the <div> element. */
|
||||
className?: string
|
||||
/** Elements inside the card. */
|
||||
|
@ -33,8 +35,9 @@ const Card = React.forwardRef<HTMLDivElement, ICard>(({ children, variant = 'def
|
|||
ref={ref}
|
||||
{...filteredProps}
|
||||
className={clsx({
|
||||
'bg-white dark:bg-primary-900 text-gray-900 dark:text-gray-100 shadow-lg dark:shadow-none overflow-hidden': variant === 'rounded',
|
||||
'bg-white dark:bg-primary-900 text-gray-900 dark:text-gray-100 shadow-lg dark:shadow-none': variant === 'rounded',
|
||||
[sizes[size]]: variant === 'rounded',
|
||||
'py-4': variant === 'slim',
|
||||
}, className)}
|
||||
>
|
||||
{children}
|
||||
|
@ -72,7 +75,7 @@ const CardHeader: React.FC<ICardHeader> = ({ className, children, backHref, onBa
|
|||
};
|
||||
|
||||
return (
|
||||
<HStack alignItems='center' space={2} className={clsx('mb-4', className)}>
|
||||
<HStack alignItems='center' space={2} className={className}>
|
||||
{renderBackButton()}
|
||||
|
||||
{children}
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import throttle from 'lodash/throttle';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import Helmet from 'soapbox/components/helmet';
|
||||
import { useSoapboxConfig } from 'soapbox/hooks';
|
||||
|
||||
import { Card, CardBody, CardHeader, CardTitle } from '../card/card';
|
||||
import { Card, CardBody, CardHeader, CardTitle, type CardSizes } from '../card/card';
|
||||
|
||||
type IColumnHeader = Pick<IColumn, 'label' | 'backHref' | 'className' | 'action'>;
|
||||
|
||||
|
@ -54,13 +55,29 @@ export interface IColumn {
|
|||
ref?: React.Ref<HTMLDivElement>
|
||||
/** Children to display in the column. */
|
||||
children?: React.ReactNode
|
||||
/** Action for the ColumnHeader, displayed at the end. */
|
||||
action?: React.ReactNode
|
||||
/** Column size, inherited from Card. */
|
||||
size?: CardSizes
|
||||
}
|
||||
|
||||
/** A backdrop for the main section of the UI. */
|
||||
const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedRef<HTMLDivElement>): JSX.Element => {
|
||||
const { backHref, children, label, transparent = false, withHeader = true, className, action } = props;
|
||||
const { backHref, children, label, transparent = false, withHeader = true, className, action, size } = props;
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
|
||||
const handleScroll = useCallback(throttle(() => {
|
||||
setIsScrolled(window.pageYOffset > 32);
|
||||
}, 50), []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div role='region' className='relative' ref={ref} aria-label={label} column-type={transparent ? 'transparent' : 'filled'}>
|
||||
|
@ -76,12 +93,18 @@ const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedR
|
|||
)}
|
||||
</Helmet>
|
||||
|
||||
<Card variant={transparent ? undefined : 'rounded'} className={className}>
|
||||
<Card size={size} variant={transparent ? undefined : 'rounded'} className={className}>
|
||||
{withHeader && (
|
||||
<ColumnHeader
|
||||
label={label}
|
||||
backHref={backHref}
|
||||
className={clsx({ 'px-4 pt-4 sm:p-0': transparent })}
|
||||
className={clsx({
|
||||
'rounded-t-3xl': !isScrolled && !transparent,
|
||||
'sticky top-12 z-10 bg-white/90 dark:bg-primary-900/90 backdrop-blur lg:top-16': !transparent,
|
||||
'p-4 sm:p-0 sm:pb-4': transparent,
|
||||
'-mt-4 -mx-4 p-4': size !== 'lg' && !transparent,
|
||||
'-mt-4 -mx-4 p-4 sm:-mt-6 sm:-mx-6 sm:p-6': size === 'lg' && !transparent,
|
||||
})}
|
||||
action={action}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -69,14 +69,14 @@ const Streamfield: React.FC<IStreamfield> = ({
|
|||
</Stack>
|
||||
|
||||
{(values.length > 0) && (
|
||||
<Stack>
|
||||
<Stack space={1}>
|
||||
{values.map((value, i) => value?._destroy ? null : (
|
||||
<HStack space={2} alignItems='center'>
|
||||
<Component key={i} onChange={handleChange(i)} value={value} />
|
||||
{values.length > minItems && onRemoveItem && (
|
||||
<IconButton
|
||||
iconClassName='h-4 w-4'
|
||||
className='bg-transparent text-gray-400 hover:text-gray-600'
|
||||
className='bg-transparent text-gray-600 hover:text-gray-600'
|
||||
src={require('@tabler/icons/x.svg')}
|
||||
onClick={() => onRemoveItem(i)}
|
||||
title={intl.formatMessage(messages.remove)}
|
||||
|
@ -87,11 +87,9 @@ const Streamfield: React.FC<IStreamfield> = ({
|
|||
</Stack>
|
||||
)}
|
||||
|
||||
{onAddItem && (
|
||||
{(onAddItem && (values.length < maxItems)) && (
|
||||
<Button
|
||||
icon={require('@tabler/icons/plus.svg')}
|
||||
onClick={onAddItem}
|
||||
disabled={values.length >= maxItems}
|
||||
theme='secondary'
|
||||
block
|
||||
>
|
||||
|
|
|
@ -8,6 +8,8 @@ import { ToastText, ToastType } from 'soapbox/toast';
|
|||
|
||||
import HStack from '../hstack/hstack';
|
||||
import Icon from '../icon/icon';
|
||||
import Stack from '../stack/stack';
|
||||
import Text from '../text/text';
|
||||
|
||||
const renderText = (text: ToastText) => {
|
||||
if (typeof text === 'string') {
|
||||
|
@ -24,13 +26,14 @@ interface IToast {
|
|||
action?(): void
|
||||
actionLink?: string
|
||||
actionLabel?: ToastText
|
||||
summary?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Customizable Toasts for in-app notifications.
|
||||
*/
|
||||
const Toast = (props: IToast) => {
|
||||
const { t, message, type, action, actionLink, actionLabel } = props;
|
||||
const { t, message, type, action, actionLink, actionLabel, summary } = props;
|
||||
|
||||
const dismissToast = () => toast.dismiss(t.id);
|
||||
|
||||
|
@ -109,6 +112,7 @@ const Toast = (props: IToast) => {
|
|||
})
|
||||
}
|
||||
>
|
||||
<Stack space={2}>
|
||||
<HStack space={4} alignItems='start'>
|
||||
<HStack space={3} justifyContent='between' alignItems='start' className='w-0 flex-1'>
|
||||
<HStack space={3} alignItems='start' className='w-0 flex-1'>
|
||||
|
@ -116,9 +120,14 @@ const Toast = (props: IToast) => {
|
|||
{renderIcon()}
|
||||
</div>
|
||||
|
||||
<p className='pt-0.5 text-sm text-gray-900 dark:text-gray-100' data-testid='toast-message'>
|
||||
<Text
|
||||
size='sm'
|
||||
data-testid='toast-message'
|
||||
className='pt-0.5'
|
||||
weight={typeof summary === 'undefined' ? 'normal' : 'medium'}
|
||||
>
|
||||
{renderText(message)}
|
||||
</p>
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* Action */}
|
||||
|
@ -138,6 +147,11 @@ const Toast = (props: IToast) => {
|
|||
</button>
|
||||
</div>
|
||||
</HStack>
|
||||
|
||||
{summary ? (
|
||||
<Text theme='muted' size='sm'>{summary}</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -26,6 +26,7 @@ const Toggle: React.FC<IToggle> = ({ id, size = 'md', name, checked, onChange, r
|
|||
'cursor-default': disabled,
|
||||
})}
|
||||
onClick={handleClick}
|
||||
type='button'
|
||||
>
|
||||
<div className={clsx('rounded-full bg-white transition-transform', {
|
||||
'h-4.5 w-4.5': size === 'sm',
|
||||
|
|
|
@ -110,7 +110,7 @@ test('import entities with override', () => {
|
|||
|
||||
const now = new Date();
|
||||
|
||||
const action = entitiesFetchSuccess(entities, 'TestEntity', 'thingies', {
|
||||
const action = entitiesFetchSuccess(entities, 'TestEntity', 'thingies', 'end', {
|
||||
next: undefined,
|
||||
prev: undefined,
|
||||
totalCount: 2,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { Entity, EntityListState } from './types';
|
||||
import type { Entity, EntityListState, ImportPosition } from './types';
|
||||
|
||||
const ENTITIES_IMPORT = 'ENTITIES_IMPORT' as const;
|
||||
const ENTITIES_DELETE = 'ENTITIES_DELETE' as const;
|
||||
|
@ -10,12 +10,13 @@ const ENTITIES_FETCH_FAIL = 'ENTITIES_FETCH_FAIL' as const;
|
|||
const ENTITIES_INVALIDATE_LIST = 'ENTITIES_INVALIDATE_LIST' as const;
|
||||
|
||||
/** Action to import entities into the cache. */
|
||||
function importEntities(entities: Entity[], entityType: string, listKey?: string) {
|
||||
function importEntities(entities: Entity[], entityType: string, listKey?: string, pos?: ImportPosition) {
|
||||
return {
|
||||
type: ENTITIES_IMPORT,
|
||||
entityType,
|
||||
entities,
|
||||
listKey,
|
||||
pos,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -62,6 +63,7 @@ function entitiesFetchSuccess(
|
|||
entities: Entity[],
|
||||
entityType: string,
|
||||
listKey?: string,
|
||||
pos?: ImportPosition,
|
||||
newState?: EntityListState,
|
||||
overwrite = false,
|
||||
) {
|
||||
|
@ -70,6 +72,7 @@ function entitiesFetchSuccess(
|
|||
entityType,
|
||||
entities,
|
||||
listKey,
|
||||
pos,
|
||||
newState,
|
||||
overwrite,
|
||||
};
|
||||
|
|
|
@ -3,5 +3,6 @@ export enum Entities {
|
|||
GROUPS = 'Groups',
|
||||
GROUP_RELATIONSHIPS = 'GroupRelationships',
|
||||
GROUP_MEMBERSHIPS = 'GroupMemberships',
|
||||
RELATIONSHIPS = 'Relationships'
|
||||
RELATIONSHIPS = 'Relationships',
|
||||
STATUSES = 'Statuses',
|
||||
}
|
|
@ -30,7 +30,7 @@ function useCreateEntity<TEntity extends Entity = Entity, Data = unknown>(
|
|||
const entity = schema.parse(result.data);
|
||||
|
||||
// TODO: optimistic updating
|
||||
dispatch(importEntities([entity], entityType, listKey));
|
||||
dispatch(importEntities([entity], entityType, listKey, 'start'));
|
||||
|
||||
if (callbacks.onSuccess) {
|
||||
callbacks.onSuccess(entity);
|
||||
|
|
|
@ -54,7 +54,7 @@ function useEntities<TEntity extends Entity>(
|
|||
const next = useListState(path, 'next');
|
||||
const prev = useListState(path, 'prev');
|
||||
|
||||
const fetchPage = async(req: EntityFn<void>, overwrite = false): Promise<void> => {
|
||||
const fetchPage = async(req: EntityFn<void>, pos: 'start' | 'end', overwrite = false): Promise<void> => {
|
||||
// Get `isFetching` state from the store again to prevent race conditions.
|
||||
const isFetching = selectListState(getState(), path, 'fetching');
|
||||
if (isFetching) return;
|
||||
|
@ -65,11 +65,12 @@ function useEntities<TEntity extends Entity>(
|
|||
const schema = opts.schema || z.custom<TEntity>();
|
||||
const entities = filteredArray(schema).parse(response.data);
|
||||
const parsedCount = realNumberSchema.safeParse(response.headers['x-total-count']);
|
||||
const totalCount = parsedCount.success ? parsedCount.data : undefined;
|
||||
|
||||
dispatch(entitiesFetchSuccess(entities, entityType, listKey, {
|
||||
dispatch(entitiesFetchSuccess(entities, entityType, listKey, pos, {
|
||||
next: getNextLink(response),
|
||||
prev: getPrevLink(response),
|
||||
totalCount: parsedCount.success ? parsedCount.data : undefined,
|
||||
totalCount: Number(totalCount) >= entities.length ? totalCount : undefined,
|
||||
fetching: false,
|
||||
fetched: true,
|
||||
error: null,
|
||||
|
@ -82,18 +83,18 @@ function useEntities<TEntity extends Entity>(
|
|||
};
|
||||
|
||||
const fetchEntities = async(): Promise<void> => {
|
||||
await fetchPage(entityFn, true);
|
||||
await fetchPage(entityFn, 'end', true);
|
||||
};
|
||||
|
||||
const fetchNextPage = async(): Promise<void> => {
|
||||
if (next) {
|
||||
await fetchPage(() => api.get(next));
|
||||
await fetchPage(() => api.get(next), 'end');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPreviousPage = async(): Promise<void> => {
|
||||
if (prev) {
|
||||
await fetchPage(() => api.get(prev));
|
||||
await fetchPage(() => api.get(prev), 'start');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ import {
|
|||
import { createCache, createList, updateStore, updateList } from './utils';
|
||||
|
||||
import type { DeleteEntitiesOpts } from './actions';
|
||||
import type { Entity, EntityCache, EntityListState } from './types';
|
||||
import type { Entity, EntityCache, EntityListState, ImportPosition } from './types';
|
||||
|
||||
enableMapSet();
|
||||
|
||||
|
@ -29,6 +29,7 @@ const importEntities = (
|
|||
entityType: string,
|
||||
entities: Entity[],
|
||||
listKey?: string,
|
||||
pos?: ImportPosition,
|
||||
newState?: EntityListState,
|
||||
overwrite = false,
|
||||
): State => {
|
||||
|
@ -43,7 +44,7 @@ const importEntities = (
|
|||
list.ids = new Set();
|
||||
}
|
||||
|
||||
list = updateList(list, entities);
|
||||
list = updateList(list, entities, pos);
|
||||
|
||||
if (newState) {
|
||||
list.state = newState;
|
||||
|
@ -159,7 +160,7 @@ const invalidateEntityList = (state: State, entityType: string, listKey: string)
|
|||
function reducer(state: Readonly<State> = {}, action: EntityAction): State {
|
||||
switch (action.type) {
|
||||
case ENTITIES_IMPORT:
|
||||
return importEntities(state, action.entityType, action.entities, action.listKey);
|
||||
return importEntities(state, action.entityType, action.entities, action.listKey, action.pos);
|
||||
case ENTITIES_DELETE:
|
||||
return deleteEntities(state, action.entityType, action.ids, action.opts);
|
||||
case ENTITIES_DISMISS:
|
||||
|
@ -167,7 +168,7 @@ function reducer(state: Readonly<State> = {}, action: EntityAction): State {
|
|||
case ENTITIES_INCREMENT:
|
||||
return incrementEntities(state, action.entityType, action.listKey, action.diff);
|
||||
case ENTITIES_FETCH_SUCCESS:
|
||||
return importEntities(state, action.entityType, action.entities, action.listKey, action.newState, action.overwrite);
|
||||
return importEntities(state, action.entityType, action.entities, action.listKey, action.pos, action.newState, action.overwrite);
|
||||
case ENTITIES_FETCH_REQUEST:
|
||||
return setFetching(state, action.entityType, action.listKey, true);
|
||||
case ENTITIES_FETCH_FAIL:
|
||||
|
|
|
@ -47,10 +47,14 @@ interface EntityCache<TEntity extends Entity = Entity> {
|
|||
}
|
||||
}
|
||||
|
||||
/** Whether to import items at the start or end of the list. */
|
||||
type ImportPosition = 'start' | 'end'
|
||||
|
||||
export {
|
||||
Entity,
|
||||
EntityStore,
|
||||
EntityList,
|
||||
EntityListState,
|
||||
EntityCache,
|
||||
ImportPosition,
|
||||
};
|
|
@ -1,4 +1,4 @@
|
|||
import type { Entity, EntityStore, EntityList, EntityCache, EntityListState } from './types';
|
||||
import type { Entity, EntityStore, EntityList, EntityCache, EntityListState, ImportPosition } from './types';
|
||||
|
||||
/** Insert the entities into the store. */
|
||||
const updateStore = (store: EntityStore, entities: Entity[]): EntityStore => {
|
||||
|
@ -9,9 +9,10 @@ const updateStore = (store: EntityStore, entities: Entity[]): EntityStore => {
|
|||
};
|
||||
|
||||
/** Update the list with new entity IDs. */
|
||||
const updateList = (list: EntityList, entities: Entity[]): EntityList => {
|
||||
const updateList = (list: EntityList, entities: Entity[], pos: ImportPosition = 'end'): EntityList => {
|
||||
const newIds = entities.map(entity => entity.id);
|
||||
const ids = new Set([...newIds, ...Array.from(list.ids)]);
|
||||
const oldIds = Array.from(list.ids);
|
||||
const ids = new Set(pos === 'start' ? [...newIds, ...oldIds] : [...oldIds, ...newIds]);
|
||||
|
||||
if (typeof list.state.totalCount === 'number') {
|
||||
const sizeDiff = ids.size - list.ids.size;
|
||||
|
|
|
@ -105,7 +105,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
|||
|
||||
if (!account) {
|
||||
return (
|
||||
<div className='-mx-4 -mt-4'>
|
||||
<div className='-mx-4 -mt-4 sm:-mx-6 sm:-mt-6'>
|
||||
<div>
|
||||
<div className='relative h-32 w-full bg-gray-200 dark:bg-gray-900/50 md:rounded-t-xl lg:h-48' />
|
||||
</div>
|
||||
|
@ -608,7 +608,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
|||
const menu = makeMenu();
|
||||
|
||||
return (
|
||||
<div className='-mx-4 -mt-4'>
|
||||
<div className='-mx-4 -mt-4 sm:-mx-6 sm:-mt-6'>
|
||||
{(account.moved && typeof account.moved === 'object') && (
|
||||
<MovedNote from={account} to={account.moved} />
|
||||
)}
|
||||
|
|
|
@ -184,7 +184,7 @@ const EventDiscussion: React.FC<IEventDiscussion> = (props) => {
|
|||
ref={scroller}
|
||||
hasMore={!!next}
|
||||
onLoadMore={handleLoadMore}
|
||||
placeholderComponent={() => <PlaceholderStatus thread />}
|
||||
placeholderComponent={() => <PlaceholderStatus variant='slim' />}
|
||||
initialTopMostItemIndex={0}
|
||||
emptyMessage={<FormattedMessage id='event.discussion.empty' defaultMessage='No one has commented this event yet. When someone does, they will appear here.' />}
|
||||
>
|
||||
|
|
|
@ -32,7 +32,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
|
|||
|
||||
if (!group) {
|
||||
return (
|
||||
<div className='-mx-4 -mt-4'>
|
||||
<div className='-mx-4 -mt-4 sm:-mx-6 sm:-mt-6'>
|
||||
<div>
|
||||
<div className='relative h-32 w-full bg-gray-200 dark:bg-gray-900/50 md:rounded-t-xl lg:h-48' />
|
||||
</div>
|
||||
|
@ -105,7 +105,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className='-mx-4 -mt-4'>
|
||||
<div className='-mx-4 -mt-4 sm:-mx-6 sm:-mt-6'>
|
||||
<div className='relative'>
|
||||
{renderHeader()}
|
||||
|
||||
|
|
|
@ -15,10 +15,14 @@ import { useAccount, useBlockGroupMember, useDemoteGroupMember, usePromoteGroupM
|
|||
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
import { MAX_ADMIN_COUNT } from '../group-members';
|
||||
|
||||
import type { Menu as IMenu } from 'soapbox/components/dropdown-menu';
|
||||
import type { Group, GroupMember } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
adminLimitTitle: { id: 'group.member.admin.limit.title', defaultMessage: 'Admin limit reached' },
|
||||
adminLimitSummary: { id: 'group.member.admin.limit.summary', defaultMessage: 'You can assign up to {count} admins for the group at this time.' },
|
||||
blockConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Ban' },
|
||||
blockFromGroupHeading: { id: 'confirmations.block_from_group.heading', defaultMessage: 'Ban From Group' },
|
||||
blockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to ban @{name} from the group?' },
|
||||
|
@ -39,10 +43,11 @@ const messages = defineMessages({
|
|||
interface IGroupMemberListItem {
|
||||
member: GroupMember
|
||||
group: Group
|
||||
canPromoteToAdmin: boolean
|
||||
}
|
||||
|
||||
const GroupMemberListItem = (props: IGroupMemberListItem) => {
|
||||
const { member, group } = props;
|
||||
const { canPromoteToAdmin, member, group } = props;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const features = useFeatures();
|
||||
|
@ -90,6 +95,13 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => {
|
|||
};
|
||||
|
||||
const handleAdminAssignment = () => {
|
||||
if (!canPromoteToAdmin) {
|
||||
toast.error(intl.formatMessage(messages.adminLimitTitle), {
|
||||
summary: intl.formatMessage(messages.adminLimitSummary, { count: MAX_ADMIN_COUNT }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.promoteConfirm),
|
||||
message: intl.formatMessage(messages.promoteConfirmMessage, { name: account?.username }),
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { Input, Streamfield } from 'soapbox/components/ui';
|
||||
|
||||
const messages = defineMessages({
|
||||
hashtagPlaceholder: { id: 'manage_group.fields.hashtag_placeholder', defaultMessage: 'Add a topic' },
|
||||
});
|
||||
|
||||
interface IGroupTagsField {
|
||||
tags: string[]
|
||||
onChange(tags: string[]): void
|
||||
onAddItem(): void
|
||||
onRemoveItem(i: number): void
|
||||
maxItems?: number
|
||||
}
|
||||
|
||||
const GroupTagsField: React.FC<IGroupTagsField> = ({ tags, onChange, onAddItem, onRemoveItem, maxItems = 3 }) => {
|
||||
return (
|
||||
<Streamfield
|
||||
label={<FormattedMessage id='group.tags.label' defaultMessage='Tags' />}
|
||||
hint={<FormattedMessage id='group.tags.hint' defaultMessage='Add up to 3 keywords that will serve as core topics of discussion in the group.' />}
|
||||
component={HashtagField}
|
||||
values={tags}
|
||||
onChange={onChange}
|
||||
onAddItem={onAddItem}
|
||||
onRemoveItem={onRemoveItem}
|
||||
maxItems={maxItems}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface IHashtagField {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
const HashtagField: React.FC<IHashtagField> = ({ value, onChange }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const handleChange: React.ChangeEventHandler<HTMLInputElement> = ({ target }) => {
|
||||
onChange(target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
outerClassName='w-full'
|
||||
type='text'
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={intl.formatMessage(messages.hashtagPlaceholder)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupTagsField;
|
|
@ -1,8 +1,7 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { Button, Column, Form, FormActions, FormGroup, Input, Spinner, Textarea } from 'soapbox/components/ui';
|
||||
import { Button, Column, Form, FormActions, FormGroup, Icon, Input, Spinner, Textarea } from 'soapbox/components/ui';
|
||||
import { useAppSelector, useInstance } from 'soapbox/hooks';
|
||||
import { useGroup, useUpdateGroup } from 'soapbox/hooks/api';
|
||||
import { useImageField, useTextField } from 'soapbox/hooks/forms';
|
||||
|
@ -10,6 +9,7 @@ import { isDefaultAvatar, isDefaultHeader } from 'soapbox/utils/accounts';
|
|||
|
||||
import AvatarPicker from './components/group-avatar-picker';
|
||||
import HeaderPicker from './components/group-header-picker';
|
||||
import GroupTagsField from './components/group-tags-field';
|
||||
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
|
||||
|
@ -20,6 +20,7 @@ const messages = defineMessages({
|
|||
heading: { id: 'navigation_bar.edit_group', defaultMessage: 'Edit Group' },
|
||||
groupNamePlaceholder: { id: 'manage_group.fields.name_placeholder', defaultMessage: 'Group Name' },
|
||||
groupDescriptionPlaceholder: { id: 'manage_group.fields.description_placeholder', defaultMessage: 'Description' },
|
||||
success: { id: 'manage_group.success', defaultMessage: 'Group saved!' },
|
||||
});
|
||||
|
||||
interface IEditGroup {
|
||||
|
@ -36,6 +37,7 @@ const EditGroup: React.FC<IEditGroup> = ({ params: { id: groupId } }) => {
|
|||
const { updateGroup } = useUpdateGroup(groupId);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [tags, setTags] = useState<string[]>(['']);
|
||||
|
||||
const avatar = useImageField({ maxPixels: 400 * 400, preview: nonDefaultAvatar(group?.avatar) });
|
||||
const header = useImageField({ maxPixels: 1920 * 1080, preview: nonDefaultHeader(group?.header) });
|
||||
|
@ -58,11 +60,28 @@ const EditGroup: React.FC<IEditGroup> = ({ params: { id: groupId } }) => {
|
|||
note: note.value,
|
||||
avatar: avatar.file,
|
||||
header: header.file,
|
||||
tags,
|
||||
});
|
||||
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
||||
const handleAddTag = () => {
|
||||
setTags([...tags, '']);
|
||||
};
|
||||
|
||||
const handleRemoveTag = (i: number) => {
|
||||
const newTags = [...tags];
|
||||
newTags.splice(i, 1);
|
||||
setTags(newTags);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (group) {
|
||||
setTags(group.tags.map((t) => t.name));
|
||||
}
|
||||
}, [group?.id]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
@ -98,6 +117,15 @@ const EditGroup: React.FC<IEditGroup> = ({ params: { id: groupId } }) => {
|
|||
/>
|
||||
</FormGroup>
|
||||
|
||||
<div className='pb-6'>
|
||||
<GroupTagsField
|
||||
tags={tags}
|
||||
onChange={setTags}
|
||||
onAddItem={handleAddTag}
|
||||
onRemoveItem={handleRemoveTag}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormActions>
|
||||
<Button theme='primary' type='submit' disabled={isSubmitting} block>
|
||||
<FormattedMessage id='edit_profile.save' defaultMessage='Save' />
|
||||
|
|
|
@ -0,0 +1,91 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import LoadMore from 'soapbox/components/load-more';
|
||||
import MissingIndicator from 'soapbox/components/missing-indicator';
|
||||
import { Column, Spinner } from 'soapbox/components/ui';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
import { useGroup, useGroupMedia } from 'soapbox/hooks/api';
|
||||
|
||||
import MediaItem from '../account-gallery/components/media-item';
|
||||
|
||||
import type { Attachment, Status } from 'soapbox/types/entities';
|
||||
|
||||
const GroupGallery = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { id: groupId } = useParams<{ id: string }>();
|
||||
|
||||
const { group, isLoading: groupIsLoading } = useGroup(groupId);
|
||||
|
||||
const {
|
||||
entities: statuses,
|
||||
fetchNextPage,
|
||||
isLoading,
|
||||
hasNextPage,
|
||||
} = useGroupMedia(groupId);
|
||||
|
||||
const attachments = statuses.reduce<Attachment[]>((result, status) => {
|
||||
result.push(...status.media_attachments.map((a) => a.set('status', status)));
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const handleOpenMedia = (attachment: Attachment) => {
|
||||
if (attachment.type === 'video') {
|
||||
dispatch(openModal('VIDEO', { media: attachment, status: attachment.status, account: attachment.account }));
|
||||
} else {
|
||||
const media = (attachment.status as Status).media_attachments;
|
||||
const index = media.findIndex((x) => x.id === attachment.id);
|
||||
|
||||
dispatch(openModal('MEDIA', { media, index, status: attachment.status }));
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading || groupIsLoading) {
|
||||
return (
|
||||
<Column>
|
||||
<Spinner />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
if (!group) {
|
||||
return (
|
||||
<MissingIndicator />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column label={group.display_name} transparent withHeader={false}>
|
||||
<div role='feed' className='grid grid-cols-2 gap-2 sm:grid-cols-3'>
|
||||
{attachments.map((attachment) => (
|
||||
<MediaItem
|
||||
key={`${attachment.status.id}+${attachment.id}`}
|
||||
attachment={attachment}
|
||||
onOpenMedia={handleOpenMedia}
|
||||
/>
|
||||
))}
|
||||
|
||||
{(!isLoading && attachments.length === 0) && (
|
||||
<div className='empty-column-indicator'>
|
||||
<FormattedMessage id='account_gallery.none' defaultMessage='No media to show.' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(hasNextPage && !isLoading) && (
|
||||
<LoadMore className='my-auto' visible={!isLoading} onClick={fetchNextPage} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className='slist__append'>
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupGallery;
|
|
@ -3,6 +3,7 @@ import React, { useMemo } from 'react';
|
|||
|
||||
import { PendingItemsRow } from 'soapbox/components/pending-items-row';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { useFeatures } from 'soapbox/hooks';
|
||||
import { useGroup } from 'soapbox/hooks/api';
|
||||
import { useGroupMembershipRequests } from 'soapbox/hooks/api/groups/useGroupMembershipRequests';
|
||||
import { useGroupMembers } from 'soapbox/hooks/api/useGroupMembers';
|
||||
|
@ -18,9 +19,13 @@ interface IGroupMembers {
|
|||
params: { id: string }
|
||||
}
|
||||
|
||||
export const MAX_ADMIN_COUNT = 5;
|
||||
|
||||
const GroupMembers: React.FC<IGroupMembers> = (props) => {
|
||||
const groupId = props.params.id;
|
||||
|
||||
const features = useFeatures();
|
||||
|
||||
const { group, isFetching: isFetchingGroup } = useGroup(groupId);
|
||||
const { groupMembers: owners, isFetching: isFetchingOwners } = useGroupMembers(groupId, GroupRoles.OWNER);
|
||||
const { groupMembers: admins, isFetching: isFetchingAdmins } = useGroupMembers(groupId, GroupRoles.ADMIN);
|
||||
|
@ -35,6 +40,10 @@ const GroupMembers: React.FC<IGroupMembers> = (props) => {
|
|||
...users,
|
||||
], [owners, admins, users]);
|
||||
|
||||
const canPromoteToAdmin = features.groupsAdminMax
|
||||
? members.filter((member) => member.role === GroupRoles.ADMIN).length < MAX_ADMIN_COUNT
|
||||
: true;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollableList
|
||||
|
@ -58,6 +67,7 @@ const GroupMembers: React.FC<IGroupMembers> = (props) => {
|
|||
group={group as Group}
|
||||
member={member}
|
||||
key={member.account.id}
|
||||
canPromoteToAdmin={canPromoteToAdmin}
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import Account from 'soapbox/components/account';
|
||||
import { AuthorizeRejectButtons } from 'soapbox/components/authorize-reject-buttons';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Column, HStack, Spinner } from 'soapbox/components/ui';
|
||||
import { useGroup } from 'soapbox/hooks/api';
|
||||
import { useGroupMembershipRequests } from 'soapbox/hooks/api/groups/useGroupMembershipRequests';
|
||||
import { useGroup, useGroupMembershipRequests } from 'soapbox/hooks/api';
|
||||
import { useGroupMembers } from 'soapbox/hooks/api/useGroupMembers';
|
||||
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
import ColumnForbidden from '../ui/components/column-forbidden';
|
||||
|
@ -59,6 +60,13 @@ const GroupMembershipRequests: React.FC<IGroupMembershipRequests> = ({ params })
|
|||
const { group } = useGroup(id);
|
||||
|
||||
const { accounts, authorize, reject, isLoading } = useGroupMembershipRequests(id);
|
||||
const { invalidate } = useGroupMembers(id, GroupRoles.USER);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
invalidate();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!group || !group.relationship || isLoading) {
|
||||
return (
|
||||
|
|
|
@ -56,7 +56,7 @@ const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
|
|||
return (
|
||||
<Stack space={2}>
|
||||
{canComposeGroupStatus && (
|
||||
<div className='border-b border-solid border-gray-200 px-2 py-4 dark:border-gray-800'>
|
||||
<div className='border-b border-solid border-gray-200 py-6 dark:border-gray-800'>
|
||||
<HStack alignItems='start' space={4}>
|
||||
<Link to={`/@${account.acct}`}>
|
||||
<Avatar src={account.avatar} size={46} />
|
||||
|
|
|
@ -26,7 +26,7 @@ export default (props: Props) => {
|
|||
const debouncedValueToSave = debounce(searchValue as string, 1000);
|
||||
|
||||
const groupSearchResult = useGroupSearch(debouncedValue);
|
||||
const { groups, isFetching, isFetched, isError } = groupSearchResult;
|
||||
const { groups, isLoading, isFetched, isError } = groupSearchResult;
|
||||
|
||||
const hasSearchResults = isFetched && groups.length > 0;
|
||||
const hasNoSearchResults = isFetched && groups.length === 0;
|
||||
|
@ -37,7 +37,7 @@ export default (props: Props) => {
|
|||
}
|
||||
}, [debouncedValueToSave]);
|
||||
|
||||
if (isFetching) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Stack space={4}>
|
||||
<PlaceholderGroupSearch />
|
||||
|
|
|
@ -43,7 +43,9 @@ const icons: Record<NotificationType, string> = {
|
|||
follow_request: require('@tabler/icons/user-plus.svg'),
|
||||
mention: require('@tabler/icons/at.svg'),
|
||||
favourite: require('@tabler/icons/heart.svg'),
|
||||
group_favourite: require('@tabler/icons/heart.svg'),
|
||||
reblog: require('@tabler/icons/repeat.svg'),
|
||||
group_reblog: require('@tabler/icons/repeat.svg'),
|
||||
status: require('@tabler/icons/bell-ringing.svg'),
|
||||
poll: require('@tabler/icons/chart-bar.svg'),
|
||||
move: require('@tabler/icons/briefcase.svg'),
|
||||
|
@ -78,10 +80,18 @@ const messages: Record<NotificationType, MessageDescriptor> = defineMessages({
|
|||
id: 'notification.favourite',
|
||||
defaultMessage: '{name} liked your post',
|
||||
},
|
||||
group_favourite: {
|
||||
id: 'notification.group_favourite',
|
||||
defaultMessage: '{name} liked your group post',
|
||||
},
|
||||
reblog: {
|
||||
id: 'notification.reblog',
|
||||
defaultMessage: '{name} reposted your post',
|
||||
},
|
||||
group_reblog: {
|
||||
id: 'notification.group_reblog',
|
||||
defaultMessage: '{name} reposted your group post',
|
||||
},
|
||||
status: {
|
||||
id: 'notification.status',
|
||||
defaultMessage: '{name} just posted',
|
||||
|
@ -314,8 +324,10 @@ const Notification: React.FC<INotificaton> = (props) => {
|
|||
/>
|
||||
) : null;
|
||||
case 'favourite':
|
||||
case 'group_favourite':
|
||||
case 'mention':
|
||||
case 'reblog':
|
||||
case 'group_reblog':
|
||||
case 'status':
|
||||
case 'poll':
|
||||
case 'update':
|
||||
|
@ -331,6 +343,7 @@ const Notification: React.FC<INotificaton> = (props) => {
|
|||
onMoveUp={handleMoveUp}
|
||||
avatarSize={avatarSize}
|
||||
contextType='notifications'
|
||||
showGroup={false}
|
||||
/>
|
||||
) : null;
|
||||
default:
|
||||
|
|
|
@ -8,15 +8,16 @@ import PlaceholderDisplayName from './placeholder-display-name';
|
|||
import PlaceholderStatusContent from './placeholder-status-content';
|
||||
|
||||
interface IPlaceholderStatus {
|
||||
thread?: boolean
|
||||
variant?: 'rounded' | 'slim'
|
||||
}
|
||||
|
||||
/** Fake status to display while data is loading. */
|
||||
const PlaceholderStatus: React.FC<IPlaceholderStatus> = ({ thread = false }) => (
|
||||
const PlaceholderStatus: React.FC<IPlaceholderStatus> = ({ variant = 'rounded' }) => (
|
||||
<div
|
||||
className={clsx({
|
||||
'status-placeholder bg-white dark:bg-primary-900': true,
|
||||
'shadow-xl dark:shadow-none sm:rounded-xl px-4 py-6 sm:p-5': !thread,
|
||||
'shadow-xl dark:shadow-none sm:rounded-xl px-4 py-6 sm:p-5': variant === 'rounded',
|
||||
'py-4': variant === 'slim',
|
||||
})}
|
||||
>
|
||||
<div className='w-full animate-pulse overflow-hidden'>
|
||||
|
|
|
@ -46,7 +46,7 @@ const ThreadStatus: React.FC<IThreadStatus> = (props): JSX.Element => {
|
|||
// @ts-ignore FIXME
|
||||
<StatusContainer {...props} />
|
||||
) : (
|
||||
<PlaceholderStatus thread />
|
||||
<PlaceholderStatus variant='slim' />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -50,8 +50,9 @@ import type {
|
|||
} from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'status.title', defaultMessage: '@{username}\'s Post' },
|
||||
title: { id: 'status.title', defaultMessage: 'Post Details' },
|
||||
titleDirect: { id: 'status.title_direct', defaultMessage: 'Direct message' },
|
||||
titleGroup: { id: 'status.title_group', defaultMessage: 'Group Post Details' },
|
||||
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
||||
deleteHeading: { id: 'confirmations.delete.heading', defaultMessage: 'Delete post' },
|
||||
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this post?' },
|
||||
|
@ -462,9 +463,6 @@ const Thread: React.FC<IThread> = (props) => {
|
|||
react: handleHotkeyReact,
|
||||
};
|
||||
|
||||
const username = String(status.getIn(['account', 'acct']));
|
||||
const titleMessage = status.visibility === 'direct' ? messages.titleDirect : messages.title;
|
||||
|
||||
const focusedStatus = (
|
||||
<div className={clsx({ 'pb-4': hasDescendants })} key={status.id}>
|
||||
<HotKeys handlers={handlers}>
|
||||
|
@ -488,7 +486,7 @@ const Thread: React.FC<IThread> = (props) => {
|
|||
|
||||
{!isUnderReview ? (
|
||||
<>
|
||||
<hr className='mb-2 border-t-2 dark:border-primary-800' />
|
||||
<hr className='-mx-4 mb-2 max-w-[100vw] border-t-2 dark:border-primary-800' />
|
||||
|
||||
<StatusActionBar
|
||||
status={status}
|
||||
|
@ -502,7 +500,7 @@ const Thread: React.FC<IThread> = (props) => {
|
|||
</HotKeys>
|
||||
|
||||
{hasDescendants && (
|
||||
<hr className='mt-2 border-t-2 dark:border-primary-800' />
|
||||
<hr className='-mx-4 mt-2 max-w-[100vw] border-t-2 dark:border-primary-800' />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
@ -523,17 +521,22 @@ const Thread: React.FC<IThread> = (props) => {
|
|||
return <Redirect to={`/groups/${status.group.id}/posts/${props.params.statusId}`} />;
|
||||
}
|
||||
|
||||
const titleMessage = () => {
|
||||
if (status.visibility === 'direct') return messages.titleDirect;
|
||||
return status.group ? messages.titleGroup : messages.title;
|
||||
};
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(titleMessage, { username })} transparent>
|
||||
<Column label={intl.formatMessage(titleMessage())}>
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<Stack space={2}>
|
||||
<Stack space={2} className='mt-2'>
|
||||
<div ref={node} className='thread'>
|
||||
<ScrollableList
|
||||
id='thread'
|
||||
ref={scroller}
|
||||
hasMore={!!next}
|
||||
onLoadMore={handleLoadMore}
|
||||
placeholderComponent={() => <PlaceholderStatus thread />}
|
||||
placeholderComponent={() => <PlaceholderStatus variant='slim' />}
|
||||
initialTopMostItemIndex={ancestorsIds.size}
|
||||
>
|
||||
{children}
|
||||
|
|
|
@ -4,6 +4,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|||
import { Form, FormGroup, Input, Textarea } from 'soapbox/components/ui';
|
||||
import AvatarPicker from 'soapbox/features/group/components/group-avatar-picker';
|
||||
import HeaderPicker from 'soapbox/features/group/components/group-header-picker';
|
||||
import GroupTagsField from 'soapbox/features/group/components/group-tags-field';
|
||||
import { useAppSelector, useDebounce, useInstance } from 'soapbox/hooks';
|
||||
import { CreateGroupParams, useGroupValidation } from 'soapbox/hooks/api';
|
||||
import { usePreview } from 'soapbox/hooks/forms';
|
||||
|
@ -14,6 +15,7 @@ import type { List as ImmutableList } from 'immutable';
|
|||
const messages = defineMessages({
|
||||
groupNamePlaceholder: { id: 'manage_group.fields.name_placeholder', defaultMessage: 'Group Name' },
|
||||
groupDescriptionPlaceholder: { id: 'manage_group.fields.description_placeholder', defaultMessage: 'Description' },
|
||||
hashtagPlaceholder: { id: 'manage_group.fields.hashtag_placeholder', defaultMessage: 'Add a topic' },
|
||||
});
|
||||
|
||||
interface IDetailsStep {
|
||||
|
@ -29,6 +31,7 @@ const DetailsStep: React.FC<IDetailsStep> = ({ params, onChange }) => {
|
|||
const {
|
||||
display_name: displayName = '',
|
||||
note = '',
|
||||
tags = [''],
|
||||
} = params;
|
||||
|
||||
const debouncedName = debounce(displayName, 300);
|
||||
|
@ -63,6 +66,29 @@ const DetailsStep: React.FC<IDetailsStep> = ({ params, onChange }) => {
|
|||
};
|
||||
};
|
||||
|
||||
const handleTagsChange = (tags: string[]) => {
|
||||
onChange({
|
||||
...params,
|
||||
tags,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddTag = () => {
|
||||
onChange({
|
||||
...params,
|
||||
tags: [...tags, ''],
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveTag = (i: number) => {
|
||||
const newTags = [...tags];
|
||||
newTags.splice(i, 1);
|
||||
onChange({
|
||||
...params,
|
||||
tags: newTags,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<div className='relative mb-12 flex'>
|
||||
|
@ -95,6 +121,15 @@ const DetailsStep: React.FC<IDetailsStep> = ({ params, onChange }) => {
|
|||
maxLength={Number(instance.configuration.getIn(['groups', 'max_characters_description']))}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<div className='pb-6'>
|
||||
<GroupTagsField
|
||||
tags={tags}
|
||||
onChange={handleTagsChange}
|
||||
onAddItem={handleAddTag}
|
||||
onRemoveItem={handleRemoveTag}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -141,7 +141,7 @@ const ProfileInfoPanel: React.FC<IProfileInfoPanel> = ({ account, username }) =>
|
|||
<Stack space={2}>
|
||||
<Stack>
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Text size='lg' weight='bold' dangerouslySetInnerHTML={displayNameHtml} />
|
||||
<Text size='lg' weight='bold' dangerouslySetInnerHTML={displayNameHtml} truncate />
|
||||
|
||||
{account.bot && <Badge slug='bot' title={intl.formatMessage(messages.bot)} />}
|
||||
|
||||
|
@ -153,7 +153,7 @@ const ProfileInfoPanel: React.FC<IProfileInfoPanel> = ({ account, username }) =>
|
|||
</HStack>
|
||||
|
||||
<HStack alignItems='center' space={0.5}>
|
||||
<Text size='sm' theme='muted' direction='ltr'>
|
||||
<Text size='sm' theme='muted' direction='ltr' truncate>
|
||||
@{displayFqn ? account.fqn : account.acct}
|
||||
</Text>
|
||||
|
||||
|
|
|
@ -34,6 +34,7 @@ import GroupPage from 'soapbox/pages/group-page';
|
|||
import GroupsPage from 'soapbox/pages/groups-page';
|
||||
import GroupsPendingPage from 'soapbox/pages/groups-pending-page';
|
||||
import HomePage from 'soapbox/pages/home-page';
|
||||
import ManageGroupsPage from 'soapbox/pages/manage-groups-page';
|
||||
import ProfilePage from 'soapbox/pages/profile-page';
|
||||
import RemoteInstancePage from 'soapbox/pages/remote-instance-page';
|
||||
import StatusPage from 'soapbox/pages/status-page';
|
||||
|
@ -116,6 +117,7 @@ import {
|
|||
EventInformation,
|
||||
EventDiscussion,
|
||||
Events,
|
||||
GroupGallery,
|
||||
Groups,
|
||||
GroupsDiscover,
|
||||
GroupsPopular,
|
||||
|
@ -297,10 +299,11 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
|
|||
{features.groupsPending && <WrappedRoute path='/groups/pending-requests' exact page={GroupsPendingPage} component={PendingGroupRequests} 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/edit' exact page={DefaultPage} component={EditGroup} 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} />}
|
||||
{features.groups && <WrappedRoute path='/groups/:id/media' publicRoute={!authenticatedProfile} component={GroupGallery} page={GroupPage} content={children} />}
|
||||
{features.groups && <WrappedRoute path='/groups/:id/manage' exact page={ManageGroupsPage} component={ManageGroup} content={children} />}
|
||||
{features.groups && <WrappedRoute path='/groups/:id/manage/edit' exact page={ManageGroupsPage} component={EditGroup} content={children} />}
|
||||
{features.groups && <WrappedRoute path='/groups/:id/manage/blocks' exact page={ManageGroupsPage} component={GroupBlockedMembers} content={children} />}
|
||||
{features.groups && <WrappedRoute path='/groups/:id/manage/requests' exact page={ManageGroupsPage} component={GroupMembershipRequests} content={children} />}
|
||||
{features.groups && <WrappedRoute path='/groups/:groupId/posts/:statusId' exact page={StatusPage} component={Status} content={children} />}
|
||||
|
||||
<WrappedRoute path='/statuses/new' page={DefaultPage} component={NewStatus} content={children} exact />
|
||||
|
|
|
@ -590,6 +590,10 @@ export function GroupMembershipRequests() {
|
|||
return import(/* webpackChunkName: "features/groups" */'../../group/group-membership-requests');
|
||||
}
|
||||
|
||||
export function GroupGallery() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../group/group-gallery');
|
||||
}
|
||||
|
||||
export function CreateGroupModal() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../components/modals/manage-group-modal/create-group-modal');
|
||||
}
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks/useApi';
|
||||
import { statusSchema } from 'soapbox/schemas/status';
|
||||
|
||||
function useGroupMedia(groupId: string) {
|
||||
const api = useApi();
|
||||
|
||||
return useEntities([Entities.STATUSES, 'groupMedia', groupId], () => {
|
||||
return api.get(`/api/v1/timelines/group/${groupId}?only_media=true`);
|
||||
}, { schema: statusSchema });
|
||||
}
|
||||
|
||||
export { useGroupMedia };
|
|
@ -2,6 +2,9 @@ import { Entities } from 'soapbox/entity-store/entities';
|
|||
import { useEntities, useIncrementEntity } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks/useApi';
|
||||
import { accountSchema } from 'soapbox/schemas';
|
||||
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||
|
||||
import { useGroupRelationship } from './useGroups';
|
||||
|
||||
import type { ExpandedEntitiesPath } from 'soapbox/entity-store/hooks/types';
|
||||
|
||||
|
@ -9,10 +12,15 @@ function useGroupMembershipRequests(groupId: string) {
|
|||
const api = useApi();
|
||||
const path: ExpandedEntitiesPath = [Entities.ACCOUNTS, 'membership_requests', groupId];
|
||||
|
||||
const { entity: relationship } = useGroupRelationship(groupId);
|
||||
|
||||
const { entities, invalidate, ...rest } = useEntities(
|
||||
path,
|
||||
() => api.get(`/api/v1/groups/${groupId}/membership_requests`),
|
||||
{ schema: accountSchema },
|
||||
{
|
||||
schema: accountSchema,
|
||||
enabled: relationship?.role === GroupRoles.OWNER || relationship?.role === GroupRoles.ADMIN,
|
||||
},
|
||||
);
|
||||
|
||||
const { incrementEntity: authorize } = useIncrementEntity(path, -1, async (accountId: string) => {
|
||||
|
|
|
@ -76,4 +76,4 @@ function useGroupRelationships(groupIds: string[]) {
|
|||
};
|
||||
}
|
||||
|
||||
export { useGroup, useGroups, useGroupRelationships };
|
||||
export { useGroup, useGroups, useGroupRelationship, useGroupRelationships };
|
||||
|
|
|
@ -11,7 +11,9 @@ export { useCancelMembershipRequest } from './groups/useCancelMembershipRequest'
|
|||
export { useCreateGroup, type CreateGroupParams } from './groups/useCreateGroup';
|
||||
export { useDeleteGroup } from './groups/useDeleteGroup';
|
||||
export { useDemoteGroupMember } from './groups/useDemoteGroupMember';
|
||||
export { useGroupMedia } from './groups/useGroupMedia';
|
||||
export { useGroup, useGroups } from './groups/useGroups';
|
||||
export { useGroupMembershipRequests } from './groups/useGroupMembershipRequests';
|
||||
export { useGroupSearch } from './groups/useGroupSearch';
|
||||
export { useGroupValidation } from './groups/useGroupValidation';
|
||||
export { useJoinGroup } from './groups/useJoinGroup';
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { GroupMember, groupMemberSchema } from 'soapbox/schemas';
|
||||
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||
|
||||
import { useApi } from '../useApi';
|
||||
|
||||
function useGroupMembers(groupId: string, role: string) {
|
||||
function useGroupMembers(groupId: string, role: GroupRoles) {
|
||||
const api = useApi();
|
||||
|
||||
const { entities, ...result } = useEntities<GroupMember>(
|
||||
|
|
|
@ -779,15 +779,18 @@
|
|||
"group.group_mod_promote_mod": "ترقية {role} الرتبة",
|
||||
"group.group_mod_reject.fail": "فشل الرفض @{name}",
|
||||
"group.group_mod_unblock": "رفع الحظر",
|
||||
"group.group_mod_unblock.success": "أُلْغِيَّ الحظر على @ {name} من المجموعة",
|
||||
"group.group_mod_unblock.success": "أُلْغِيَّ الحظر على @{name} من المجموعة",
|
||||
"group.header.alt": "غلاف المجموعة",
|
||||
"group.join.private": "طلب الدخول",
|
||||
"group.join.public": "الإنضمام إلى المجموعة",
|
||||
"group.join.request_success": "طلب الانضمام للمجموعة",
|
||||
"group.join.success": "تم الإنضمام إلى المجموعة بنجاح",
|
||||
"group.leave": "غادر المجموعة",
|
||||
"group.leave.label": "غادر",
|
||||
"group.leave.success": "غادر المجموعة",
|
||||
"group.manage": "إدارة المجموعة",
|
||||
"group.member.admin.limit.summary": "يمكنك تعيين ما يصل إلى {count} مشرفين للمجموعة في الوقت الحالي.",
|
||||
"group.member.admin.limit.title": "تم الوصول عدد المسؤولين إلى حد",
|
||||
"group.popover.action": "عرض المجموعة",
|
||||
"group.popover.summary": "يجب أن تكون عضوًا في المجموعة للرد على هذه الحالة.",
|
||||
"group.popover.title": "العضوية مطلوبة",
|
||||
|
@ -806,6 +809,7 @@
|
|||
"group.tabs.all": "الكل",
|
||||
"group.tabs.members": "الأعضاء",
|
||||
"group.upload_banner": "رفع الصورة",
|
||||
"groups.discover.popular.empty": "غير قادر على جلب المجموعات الشعبية في هذا الوقت. يرجى التحقق مرة أخرى في وقت لاحق.",
|
||||
"groups.discover.popular.show_more": "عرض المزيد",
|
||||
"groups.discover.popular.title": "مجموعات شعبية",
|
||||
"groups.discover.search.error.subtitle": "الرجاء إعادة المحاولة في وقت لاحق.",
|
||||
|
@ -813,6 +817,7 @@
|
|||
"groups.discover.search.no_results.subtitle": "حاول البحث عن مجموعة أخرى.",
|
||||
"groups.discover.search.no_results.title": "لم يتم العثور على نتائج",
|
||||
"groups.discover.search.placeholder": "بحث",
|
||||
"groups.discover.search.recent_searches.blankslate.subtitle": "ابحث عن أسماء المجموعات، الموضوعات أو الكلمات الرئيسية",
|
||||
"groups.discover.search.recent_searches.blankslate.title": "لا توجد عمليات بحث حديثة",
|
||||
"groups.discover.search.recent_searches.clear_all": "امسح الكل",
|
||||
"groups.discover.search.recent_searches.title": "عمليات البحث الأخيرة",
|
||||
|
@ -942,7 +947,6 @@
|
|||
"manage_group.delete_group": "حذف المجموعة",
|
||||
"manage_group.done": "إنتهى",
|
||||
"manage_group.edit_group": "تحرير المجموعة",
|
||||
"manage_group.edit_success": "تم تحرير المجموعة",
|
||||
"manage_group.fields.cannot_change_hint": "لا يمكن تغيير هذا بعد إنشاء المجموعة.",
|
||||
"manage_group.fields.description_label": "الوصف",
|
||||
"manage_group.fields.description_placeholder": "الوصف",
|
||||
|
@ -959,9 +963,7 @@
|
|||
"manage_group.privacy.private.label": "خاص (مطلوب موافقة المالك)",
|
||||
"manage_group.privacy.public.hint": "قابل للاكتشاف. يمكن لأي شخص الانضمام.",
|
||||
"manage_group.privacy.public.label": "عام",
|
||||
"manage_group.submit_success": "تم إنشاء المجموعة",
|
||||
"manage_group.tagline": "تربطك المجموعات بالآخرين على أساس الاهتمامات المشتركة.",
|
||||
"manage_group.update": "تحديث",
|
||||
"media_panel.empty_message": "لم يُعثر على أيّة وسائط.",
|
||||
"media_panel.title": "الوسائط",
|
||||
"mfa.confirm.success_message": "تم تأكيد إعدادات المصادقة المتعددة",
|
||||
|
|
|
@ -838,7 +838,6 @@
|
|||
"manage_group.create": "Erstellen",
|
||||
"manage_group.delete_group": "Gruppe löschen",
|
||||
"manage_group.edit_group": "Gruppe bearbeiten",
|
||||
"manage_group.edit_success": "Gruppe wurde bearbeitet",
|
||||
"manage_group.fields.description_label": "Beschreibung",
|
||||
"manage_group.fields.description_placeholder": "Beschreibung",
|
||||
"manage_group.fields.name_label": "Gruppennname (Pflichtfeld)",
|
||||
|
@ -852,9 +851,7 @@
|
|||
"manage_group.privacy.private.label": "Privat (Bestätigung durch Veranstalter erforderlich)",
|
||||
"manage_group.privacy.public.hint": "Gelistet. Jede:r kann teilnehmen.",
|
||||
"manage_group.privacy.public.label": "Öffentlich",
|
||||
"manage_group.submit_success": "Die Gruppe wurde erstellt",
|
||||
"manage_group.tagline": "Gruppen ermöglichen es dir, neue Leute auf Grundlage gemeinsamer Interessen zu finden.",
|
||||
"manage_group.update": "Update",
|
||||
"media_panel.empty_message": "Keine Medien gefunden.",
|
||||
"media_panel.title": "Media",
|
||||
"mfa.confirm.success_message": "MFA bestätigt",
|
||||
|
|
|
@ -787,6 +787,8 @@
|
|||
"group.leave.label": "Leave",
|
||||
"group.leave.success": "Left the group",
|
||||
"group.manage": "Manage Group",
|
||||
"group.member.admin.limit.summary": "You can assign up to {count} admins for the group at this time.",
|
||||
"group.member.admin.limit.title": "Admin limit reached",
|
||||
"group.popover.action": "View Group",
|
||||
"group.popover.summary": "You must be a member of the group in order to reply to this status.",
|
||||
"group.popover.title": "Membership required",
|
||||
|
@ -803,7 +805,10 @@
|
|||
"group.role.admin": "Admin",
|
||||
"group.role.owner": "Owner",
|
||||
"group.tabs.all": "All",
|
||||
"group.tabs.media": "Media",
|
||||
"group.tabs.members": "Members",
|
||||
"group.tags.hint": "Add up to 3 keywords that will serve as core topics of discussion in the group.",
|
||||
"group.tags.label": "Tags",
|
||||
"group.upload_banner": "Upload photo",
|
||||
"groups.discover.popular.empty": "Unable to fetch popular groups at this time. Please check back later.",
|
||||
"groups.discover.popular.show_more": "Show More",
|
||||
|
@ -946,6 +951,7 @@
|
|||
"manage_group.fields.cannot_change_hint": "This cannot be changed after the group is created.",
|
||||
"manage_group.fields.description_label": "Description",
|
||||
"manage_group.fields.description_placeholder": "Description",
|
||||
"manage_group.fields.hashtag_placeholder": "Add a topic",
|
||||
"manage_group.fields.name_help": "This cannot be changed after the group is created.",
|
||||
"manage_group.fields.name_label": "Group name (required)",
|
||||
"manage_group.fields.name_label_optional": "Group name",
|
||||
|
@ -959,6 +965,7 @@
|
|||
"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.success": "Group saved!",
|
||||
"manage_group.tagline": "Groups connect you with others based on shared interests.",
|
||||
"media_panel.empty_message": "No media found.",
|
||||
"media_panel.title": "Media",
|
||||
|
@ -1049,6 +1056,8 @@
|
|||
"notification.favourite": "{name} liked your post",
|
||||
"notification.follow": "{name} followed you",
|
||||
"notification.follow_request": "{name} has requested to follow you",
|
||||
"notification.group_favourite": "{name} liked your group post",
|
||||
"notification.group_reblog": "{name} reposted your group post",
|
||||
"notification.mention": "{name} mentioned you",
|
||||
"notification.mentioned": "{name} mentioned you",
|
||||
"notification.move": "{name} moved to {targetName}",
|
||||
|
@ -1452,8 +1461,9 @@
|
|||
"status.show_less_all": "Show less for all",
|
||||
"status.show_more_all": "Show more for all",
|
||||
"status.show_original": "Show original",
|
||||
"status.title": "@{username}'s post",
|
||||
"status.title": "Post Details",
|
||||
"status.title_direct": "Direct message",
|
||||
"status.title_group": "Group Post Details",
|
||||
"status.translate": "Translate",
|
||||
"status.translated_from_with": "Translated from {lang} using {provider}",
|
||||
"status.unbookmark": "Remove bookmark",
|
||||
|
|
|
@ -935,7 +935,6 @@
|
|||
"manage_group.delete_group": "Borrar el grupo",
|
||||
"manage_group.done": "Hecho",
|
||||
"manage_group.edit_group": "Editar el grupo",
|
||||
"manage_group.edit_success": "El grupo ha sido editado",
|
||||
"manage_group.fields.description_label": "Descripción",
|
||||
"manage_group.fields.description_placeholder": "Descripción",
|
||||
"manage_group.fields.name_label": "Nombre del grupo (obligatorio)",
|
||||
|
@ -949,9 +948,7 @@
|
|||
"manage_group.privacy.private.label": "Privado (requiere aprobación del propietario)",
|
||||
"manage_group.privacy.public.hint": "Público. Cualquiera puede participar.",
|
||||
"manage_group.privacy.public.label": "Público",
|
||||
"manage_group.submit_success": "El grupo se creó",
|
||||
"manage_group.tagline": "Los grupos te conectan con otras personas en función de los intereses comunes.",
|
||||
"manage_group.update": "Actualizar",
|
||||
"media_panel.empty_message": "No media found.",
|
||||
"media_panel.title": "Media",
|
||||
"mfa.confirm.success_message": "MFA confirmed",
|
||||
|
|
|
@ -883,7 +883,6 @@
|
|||
"manage_group.create": "Crea",
|
||||
"manage_group.delete_group": "Elimina gruppo",
|
||||
"manage_group.edit_group": "Modifica gruppo",
|
||||
"manage_group.edit_success": "Hai modificato il gruppo",
|
||||
"manage_group.fields.description_label": "Descrizione",
|
||||
"manage_group.fields.description_placeholder": "Descrizione",
|
||||
"manage_group.fields.name_label": "Nome del gruppo (obbligatorio)",
|
||||
|
@ -897,9 +896,7 @@
|
|||
"manage_group.privacy.private.label": "Privato (approvazione richiesta)",
|
||||
"manage_group.privacy.public.hint": "Esibito, può partecipare chiunque.",
|
||||
"manage_group.privacy.public.label": "Pubblico",
|
||||
"manage_group.submit_success": "Hai creato il gruppo",
|
||||
"manage_group.tagline": "I gruppi ti collegano ad altre persone con interessi in comune.",
|
||||
"manage_group.update": "Aggiorna",
|
||||
"media_panel.empty_message": "Nessun media.",
|
||||
"media_panel.title": "Media",
|
||||
"mfa.confirm.success_message": "Hai attivato l'autenticazione a due fattori",
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
"account.direct": "პირდაპირი წერილი @{name}-ს",
|
||||
"account.edit_profile": "პროფილის ცვლილება",
|
||||
"account.endorse": "გამორჩევა პროფილზე",
|
||||
"account.familiar_followers.more": "{count} {count, plural, one {other} other {others}} you follow",
|
||||
"account.familiar_followers.more": "{რაოდენობა, მრავლობითი, ერთი {# სხვა} სხვა {# სხვა}}-ს მიყვებით",
|
||||
"account.follow": "გაყოლა",
|
||||
"account.followers": "მიმდევრები",
|
||||
"account.follows": "მიდევნებები",
|
||||
|
@ -88,7 +88,7 @@
|
|||
"column.notifications": "შეტყობინებები",
|
||||
"column.public": "ფედერალური თაიმლაინი",
|
||||
"compose.character_counter.title": "Used {chars} out of {maxChars} characters",
|
||||
"compose.submit_success": "Your post was sent",
|
||||
"compose.submit_success": "თქვენი პოსტი გაიგზავნა!",
|
||||
"compose_form.direct_message_warning": "ეს ტუტი გაეგზავნება მხოლოდ ნახსენებ მომხმარებლებს.",
|
||||
"compose_form.hashtag_warning": "ეს ტუტი არ მოექცევა ჰეშტეგების ქვეს, რამეთუ ის არაა მითითებული. მხოლოდ ღია ტუტები მოიძებნება ჰეშტეგით.",
|
||||
"compose_form.lock_disclaimer": "თქვენი ანგარიში არაა {locked}. ნებისმიერს შეიძლია გამოგყვეთ, რომ იხილოს თქვენი მიმდევრებზე გათვლილი პოსტები.",
|
||||
|
@ -150,11 +150,11 @@
|
|||
"emoji_button.food": "საჭმელი და სასლმელი",
|
||||
"emoji_button.label": "ემოჯის ჩასმა",
|
||||
"emoji_button.nature": "ბუმება",
|
||||
"emoji_button.not_found": "არაა ემოჯი!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.not_found": "ემოჯიების გარეშე.",
|
||||
"emoji_button.objects": "ობიექტები",
|
||||
"emoji_button.people": "ხალხი",
|
||||
"emoji_button.recent": "ხშირად გამოყენებული",
|
||||
"emoji_button.search": "ძებნა...",
|
||||
"emoji_button.search": "ძებნა…",
|
||||
"emoji_button.search_results": "ძებნის შედეგები",
|
||||
"emoji_button.symbols": "სიმბოლოები",
|
||||
"emoji_button.travel": "მოგზაურობა და ადგილები",
|
||||
|
@ -216,17 +216,17 @@
|
|||
"lists.new.title_placeholder": "ახალი სიის სათაური",
|
||||
"lists.search": "ძებნა ადამიანებს შორის რომელთაც მიჰყვებით",
|
||||
"lists.subheading": "თქვენი სიები",
|
||||
"loading_indicator.label": "იტვირთება...",
|
||||
"loading_indicator.label": "ჩატვირთვა…",
|
||||
"login.fields.instance_placeholder": "example.com",
|
||||
"login.fields.password_placeholder": "Password",
|
||||
"login.log_in": "Log in",
|
||||
"login.sign_in": "Sign in",
|
||||
"login_form.header": "Sign In",
|
||||
"media_panel.title": "Media",
|
||||
"mfa.mfa_disable_enter_password": "Enter your current password to disable two-factor auth:",
|
||||
"mfa.mfa_disable_enter_password": "2FA-ის გამოსართავად შეიყვანეთ თქვენი მიმდინარე პაროლი.",
|
||||
"mfa.mfa_setup.code_placeholder": "Code",
|
||||
"mfa.mfa_setup.password_placeholder": "Password",
|
||||
"mfa.mfa_setup_scan_description": "Using your two-factor app, scan this QR code or enter text key:",
|
||||
"mfa.mfa_setup_scan_description": "თქვენი 2FA აპით დაასკანირეთ ეს QR კოდი, ან ტექსტი ხელით შეიყვანეთ.",
|
||||
"missing_description_modal.continue": "Post",
|
||||
"missing_indicator.label": "არაა ნაპოვნი",
|
||||
"missing_indicator.sublabel": "ამ რესურსის პოვნა ვერ მოხერხდა",
|
||||
|
@ -382,7 +382,7 @@
|
|||
"upload_error.video_duration_limit": "Video exceeds the current duration limit ({limit} seconds)",
|
||||
"upload_form.description": "აღწერილობა ვიზუალურად უფასურისთვის",
|
||||
"upload_form.undo": "გაუქმება",
|
||||
"upload_progress.label": "იტვირთება...",
|
||||
"upload_progress.label": "ატვირთვა…",
|
||||
"video.close": "ვიდეოს დახურვა",
|
||||
"video.download": "Download file",
|
||||
"video.exit_fullscreen": "სრულ ეკრანზე ჩვენების გათიშვა",
|
||||
|
|
|
@ -316,6 +316,7 @@
|
|||
"column.developers.service_worker": "Servicearbeider",
|
||||
"column.direct": "Direktemeldinger",
|
||||
"column.directory": "Utforsk profiler",
|
||||
"column.dislikes": "Mislikt",
|
||||
"column.domain_blocks": "Skjulte domener",
|
||||
"column.edit_profile": "Rediger profil",
|
||||
"column.event_map": "Sted for arrangementet",
|
||||
|
@ -338,11 +339,15 @@
|
|||
"column.filters.edit": "Rediger",
|
||||
"column.filters.expires": "Utløper etter",
|
||||
"column.filters.hide_header": "Skjul fullstendig",
|
||||
"column.filters.hide_hint": "Skjul filtrert innhold helt og holdent, istedenfor visning av advarsel",
|
||||
"column.filters.home_timeline": "Hjemmetidslinje",
|
||||
"column.filters.keyword": "Nøkkelord eller frase",
|
||||
"column.filters.keywords": "Nøkkelord eller begrep",
|
||||
"column.filters.notifications": "Varslinger",
|
||||
"column.filters.public_timeline": "Offentlig tidslinje",
|
||||
"column.filters.subheading_add_new": "Legg til nytt filter",
|
||||
"column.filters.title": "Navn",
|
||||
"column.filters.whole_word": "Helt ord",
|
||||
"column.follow_requests": "Følgeforespørsler",
|
||||
"column.followers": "Følgere",
|
||||
"column.following": "Følger",
|
||||
|
@ -438,6 +443,7 @@
|
|||
"compose_form.spoiler_placeholder": "Innholdsadvarsel",
|
||||
"compose_form.spoiler_remove": "Fjern følsomhet",
|
||||
"compose_form.spoiler_title": "Følsomt innhold",
|
||||
"compose_group.share_to_followers": "Del med følgerne mine",
|
||||
"confirmation_modal.cancel": "Avbryt",
|
||||
"confirmations.admin.deactivate_user.confirm": "Deaktiver @{name}",
|
||||
"confirmations.admin.deactivate_user.heading": "Deaktiver @{acct}",
|
||||
|
@ -623,6 +629,7 @@
|
|||
"email_verifilcation.exists": "Denne e-posten er allerede tatt.",
|
||||
"embed.instructions": "Kopier koden under for å bygge inn denne statusen på hjemmesiden din.",
|
||||
"emoji_button.activity": "Aktivitet",
|
||||
"emoji_button.add_custom": "Legg til egendefinert smilefjes",
|
||||
"emoji_button.custom": "Tilpasset",
|
||||
"emoji_button.flags": "Flagg",
|
||||
"emoji_button.food": "Mat og drikke",
|
||||
|
@ -630,11 +637,19 @@
|
|||
"emoji_button.nature": "Natur",
|
||||
"emoji_button.not_found": "Ingen emojier funnet.",
|
||||
"emoji_button.objects": "Objekter",
|
||||
"emoji_button.oh_no": "Oida.",
|
||||
"emoji_button.people": "Mennesker",
|
||||
"emoji_button.pick": "Velg et smilefjes …",
|
||||
"emoji_button.recent": "Hyppig brukt",
|
||||
"emoji_button.search": "Søk…",
|
||||
"emoji_button.search_results": "Søkeresultat",
|
||||
"emoji_button.skins_1": "Forvalg",
|
||||
"emoji_button.skins_2": "Hvit",
|
||||
"emoji_button.skins_3": "Lys",
|
||||
"emoji_button.skins_4": "Middels",
|
||||
"emoji_button.skins_5": "Mørk",
|
||||
"emoji_button.skins_6": "Svart",
|
||||
"emoji_button.skins_choose": "Velg forvalgt hudfarge",
|
||||
"emoji_button.symbols": "Symboler",
|
||||
"emoji_button.travel": "Reise & steder",
|
||||
"empty_column.account_blocked": "Du er blokkert av @{accountUsername}.",
|
||||
|
@ -648,6 +663,7 @@
|
|||
"empty_column.bookmarks": "Du har ingen bokmerker ennå. Når du legger til en, vises den her.",
|
||||
"empty_column.community": "Den lokale tidslinjen er tom. Skriv noe offentlig for å få snøballen til å rulle!",
|
||||
"empty_column.direct": "Du har ingen direktemeldinger ennå. Når du sender eller mottar en, vises den her.",
|
||||
"empty_column.dislikes": "Ingen har mislikt dette innlegget enda. Når noen gjør det vil det vises her.",
|
||||
"empty_column.domain_blocks": "Det er ingen skjulte domener ennå.",
|
||||
"empty_column.event_participant_requests": "Det er ingen ventende forespørsler om deltakelse på arrangementer.",
|
||||
"empty_column.event_participants": "Ingen har blitt med på dette arrangementet ennå. Når noen gjør det, vil de dukke opp her.",
|
||||
|
@ -740,6 +756,7 @@
|
|||
"filters.filters_list_expired": "Utløpt",
|
||||
"filters.filters_list_hide": "Skjul",
|
||||
"filters.filters_list_hide_completely": "Skjul innhold",
|
||||
"filters.filters_list_phrases_label": "Nøkkelord eller begrep:",
|
||||
"filters.filters_list_warn": "Vis advarsel",
|
||||
"filters.removed": "Filter slettet.",
|
||||
"followRecommendations.heading": "Foreslåtte Profiler",
|
||||
|
@ -751,12 +768,16 @@
|
|||
"gdpr.title": "{siteTitle} bruker informasjonskapsler",
|
||||
"getting_started.open_source_notice": "{code_name} er fri programvare. Du kan bidra eller rapportere problemer på GitLab på {code_link} (v{code_version}).",
|
||||
"group.cancel_request": "Avbryt forespørsel",
|
||||
"group.delete.success": "Gruppe slettet",
|
||||
"group.demote.user.success": "@{name} er nå medlem",
|
||||
"group.group_mod_authorize.fail": "Klarte ikke å godkjenne @{name}",
|
||||
"group.group_mod_block": "Blokker @{name} fra gruppe",
|
||||
"group.group_mod_block.success": "Blokkert @{navn} fra gruppe",
|
||||
"group.group_mod_demote": "Degradere @{navn}",
|
||||
"group.group_mod_kick": "Spark ut @{name} fra gruppa",
|
||||
"group.group_mod_kick.success": "Sparket ut @{name} fra gruppa",
|
||||
"group.group_mod_promote_mod": "Forfrem @{name} til gruppemoderator",
|
||||
"group.group_mod_reject.fail": "Klarte ikke å avslå @{name}",
|
||||
"group.group_mod_unblock": "Opphev Blokkering",
|
||||
"group.group_mod_unblock.success": "Opphevet blokkeringen av @{navn} fra gruppa",
|
||||
"group.header.alt": "Gruppeoverskrift",
|
||||
|
@ -765,30 +786,52 @@
|
|||
"group.join.request_success": "Har bedt om å bli med i gruppa",
|
||||
"group.join.success": "Ble med i gruppa",
|
||||
"group.leave": "Forlat gruppa",
|
||||
"group.leave.label": "Forlat",
|
||||
"group.leave.success": "Forlot gruppa",
|
||||
"group.manage": "Behandle gruppe",
|
||||
"group.member.admin.limit.summary": "Du kan tilknytte maks. {count} administratorer for denne gruppen nå.",
|
||||
"group.member.admin.limit.title": "Administratorgrense nådd",
|
||||
"group.popover.action": "Vis gruppe",
|
||||
"group.popover.summary": "Du må være medlem av gruppen for å besvare denne statusen.",
|
||||
"group.popover.title": "Medlemskap kreves",
|
||||
"group.privacy.locked": "Privat",
|
||||
"group.privacy.locked.full": "Privat gruppe",
|
||||
"group.privacy.locked.info": "Oppdagbar. Brukere kan ta del ved godkjent forespørsel.",
|
||||
"group.privacy.public": "Offentlig",
|
||||
"group.privacy.public.full": "Offentlig gruppe",
|
||||
"group.privacy.public.info": "Oppdagbar. Alle kan ta del.",
|
||||
"group.promote.admin.confirmation.message": "Tildel @{name} administratorrolle?",
|
||||
"group.promote.admin.confirmation.title": "Tildel administratorrolle",
|
||||
"group.promote.admin.success": "@{name} er nå administrator",
|
||||
"group.report.label": "Rapport",
|
||||
"group.role.admin": "Administrator",
|
||||
"group.role.owner": "Eier",
|
||||
"group.tabs.all": "Alle",
|
||||
"group.tabs.members": "Medlemmer",
|
||||
"group.tags.hint": "Legg til maks. 3 nøkkelord som tjener som kjerneemner for diskusjoner i gruppen.",
|
||||
"group.tags.label": "Etiketter",
|
||||
"group.upload_banner": "Last opp bilde",
|
||||
"groups.discover.popular.empty": "Kunne ikke hente populære grupper nå. Sjekk igjen senere.",
|
||||
"groups.discover.popular.show_more": "Vis mer",
|
||||
"groups.discover.popular.title": "Populære grupper",
|
||||
"groups.discover.search.error.subtitle": "Prøv igjen senere.",
|
||||
"groups.discover.search.error.title": "En feil oppstod",
|
||||
"groups.discover.search.no_results.subtitle": "Prøv å søke etter en annen gruppe.",
|
||||
"groups.discover.search.no_results.title": "Ingen samsvarende treff",
|
||||
"groups.discover.search.placeholder": "Søk",
|
||||
"groups.discover.search.recent_searches.blankslate.subtitle": "Søk etter gruppenavn, emner, eller nøkkelord",
|
||||
"groups.discover.search.recent_searches.blankslate.title": "Ingen nylige søk",
|
||||
"groups.discover.search.recent_searches.clear_all": "Tøm alt",
|
||||
"groups.discover.search.recent_searches.title": "Nylige søk",
|
||||
"groups.discover.search.results.groups": "Grupper",
|
||||
"groups.discover.suggested.empty": "Kunne ikke hente foreslåtte grupper nå. Sjekk igjen senere.",
|
||||
"groups.discover.suggested.show_more": "Vis mer",
|
||||
"groups.discover.suggested.title": "Foreslått for deg",
|
||||
"groups.empty.subtitle": "Begynn å oppdage grupper du kan bli med i, eller opprett din egen.",
|
||||
"groups.empty.title": "Ingen grupper ennå",
|
||||
"groups.pending.empty.subtitle": "Du har ingen ventende forespørsler.",
|
||||
"groups.pending.empty.title": "Ingen ventende forespørsler",
|
||||
"groups.pending.label": "Ventende forespørsler",
|
||||
"groups.popular.label": "Foreslåtte grupper",
|
||||
"groups.search.placeholder": "Søk i mine grupper",
|
||||
"hashtag.column_header.tag_mode.all": "og {additional}",
|
||||
|
@ -895,15 +938,22 @@
|
|||
"login_form.header": "Sign In",
|
||||
"manage_group.blocked_members": "Blokkerte medlemmer",
|
||||
"manage_group.confirmation.copy": "Kopier lenke",
|
||||
"manage_group.confirmation.info_1": "Som eieren av gruppen kan du endre brukerroller, slette innlegg, med mer.",
|
||||
"manage_group.confirmation.info_2": "Begynn med det første innlegget i gruppen og få fart på samtalen.",
|
||||
"manage_group.confirmation.info_3": "Del din nye gruppe med venner, familie og følgere for å øke medlemsmassen.",
|
||||
"manage_group.confirmation.share": "Del denne gruppen",
|
||||
"manage_group.confirmation.title": "Du er klar.",
|
||||
"manage_group.create": "Opprette",
|
||||
"manage_group.delete_group": "Slett gruppe",
|
||||
"manage_group.done": "Ferdig",
|
||||
"manage_group.edit_group": "Rediger gruppe",
|
||||
"manage_group.edit_success": "Gruppen ble redigert",
|
||||
"manage_group.fields.cannot_change_hint": "Dette kan ikke endres etter at gruppen er opprettet.",
|
||||
"manage_group.fields.description_label": "Beskrivelse",
|
||||
"manage_group.fields.description_placeholder": "Beskrivelse",
|
||||
"manage_group.fields.hashtag_placeholder": "Legg til et emne",
|
||||
"manage_group.fields.name_help": "Dette kan ikke endres etter at gruppen er opprettet.",
|
||||
"manage_group.fields.name_label": "Gruppenavn (påkrevd)",
|
||||
"manage_group.fields.name_label_optional": "Gruppenavn",
|
||||
"manage_group.fields.name_placeholder": "Navn på gruppe",
|
||||
"manage_group.get_started": "La oss komme i gang!",
|
||||
"manage_group.next": "Neste",
|
||||
|
@ -914,9 +964,8 @@
|
|||
"manage_group.privacy.private.label": "Privat (Eierens godkjenning kreves)",
|
||||
"manage_group.privacy.public.hint": "Kan oppdages. Alle kan bli med.",
|
||||
"manage_group.privacy.public.label": "Offentlig",
|
||||
"manage_group.submit_success": "Gruppa ble opprettet",
|
||||
"manage_group.success": "Gruppe lagret.",
|
||||
"manage_group.tagline": "Grupper setter deg i kontakt med andre basert på felles interesser.",
|
||||
"manage_group.update": "Oppdater",
|
||||
"media_panel.empty_message": "Ingen medier funnet.",
|
||||
"media_panel.title": "Media",
|
||||
"mfa.confirm.success_message": "MFA bekreftet",
|
||||
|
@ -1177,6 +1226,8 @@
|
|||
"remote_instance.pin_host": "Pin {host}",
|
||||
"remote_instance.unpin_host": "Løsne {host}",
|
||||
"remote_interaction.account_placeholder": "Skriv inn brukernavn@domene du vil handle fra",
|
||||
"remote_interaction.dislike": "Gå videre til misliking",
|
||||
"remote_interaction.dislike_title": "Mislik et innlegg annensteds fra",
|
||||
"remote_interaction.divider": "or",
|
||||
"remote_interaction.event_join": "Fortsett for å bli med",
|
||||
"remote_interaction.event_join_title": "Bli med på et arrangement eksternt",
|
||||
|
@ -1360,6 +1411,7 @@
|
|||
"status.detailed_status": "Detaljert samtalevisning",
|
||||
"status.direct": "Direktemelding @{name}",
|
||||
"status.disabled_replies.group_membership": "Bare gruppemedlemmer kan svare",
|
||||
"status.disfavourite": "Mislik",
|
||||
"status.edit": "Rediger",
|
||||
"status.embed": "Bygge inn",
|
||||
"status.external": "View post on {domain}",
|
||||
|
|
|
@ -835,7 +835,6 @@
|
|||
"manage_group.privacy.public.hint": "Widoczna w mechanizmach odkrywania. Każdy może dołączyć.",
|
||||
"manage_group.privacy.public.label": "Publiczna",
|
||||
"manage_group.tagline": "Grupy pozwalają łączyć ludzi o podobnych zainteresowaniach.",
|
||||
"manage_group.update": "Aktualizuj",
|
||||
"media_panel.empty_message": "Nie znaleziono mediów.",
|
||||
"media_panel.title": "Media",
|
||||
"mfa.confirm.success_message": "Potwierdzono MFA",
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -351,7 +351,7 @@
|
|||
"column.follow_requests": "关注请求",
|
||||
"column.followers": "关注者",
|
||||
"column.following": "正在关注",
|
||||
"column.group_blocked_members": "已屏蔽成员",
|
||||
"column.group_blocked_members": "已封禁成员",
|
||||
"column.group_pending_requests": "待处理的申请",
|
||||
"column.groups": "群组",
|
||||
"column.home": "主页",
|
||||
|
@ -673,7 +673,7 @@
|
|||
"empty_column.follow_recommendations": "似乎暂未有推荐信息,您可以尝试搜索用户或者浏览热门标签。",
|
||||
"empty_column.follow_requests": "您没有收到新的关注请求。收到了之后就会显示在这里。",
|
||||
"empty_column.group": "此群组还没有帖文。",
|
||||
"empty_column.group_blocks": "此群组还没有屏蔽任何用户。",
|
||||
"empty_column.group_blocks": "此群组还没有封禁任何用户。",
|
||||
"empty_column.group_membership_requests": "此群组没有待处理的成员申请。",
|
||||
"empty_column.hashtag": "此话题标签下暂时没有内容。",
|
||||
"empty_column.home": "您还没有关注任何用户。快看看 {public} ,向其他人问个好吧。",
|
||||
|
@ -778,16 +778,19 @@
|
|||
"group.group_mod_kick.success": "已从群组中踢出 @{name}",
|
||||
"group.group_mod_promote_mod": "分配 {role} 职务",
|
||||
"group.group_mod_reject.fail": "拒绝 @{name} 失败",
|
||||
"group.group_mod_unblock": "解除屏蔽",
|
||||
"group.group_mod_unblock.success": "已从群组中解除屏蔽 @{name}",
|
||||
"group.group_mod_unblock": "解除封禁",
|
||||
"group.group_mod_unblock.success": "已从群组中解除封禁 @{name}",
|
||||
"group.header.alt": "群组标题",
|
||||
"group.join.private": "申请加入群组",
|
||||
"group.join.public": "加入群组",
|
||||
"group.join.request_success": "已申请加入群组",
|
||||
"group.join.request_success": "申请已发送至群组拥有者",
|
||||
"group.join.success": "已成功加入群组!",
|
||||
"group.leave": "离开群组",
|
||||
"group.leave.label": "离开",
|
||||
"group.leave.success": "离开了群组",
|
||||
"group.manage": "管理群组",
|
||||
"group.member.admin.limit.summary": "目前您可以为群组分配最多 {count} 个管理员。",
|
||||
"group.member.admin.limit.title": "已达到管理员个数限制",
|
||||
"group.popover.action": "查看群组",
|
||||
"group.popover.summary": "您必须是群组成员才能回复此状态。",
|
||||
"group.popover.title": "需要成员身份",
|
||||
|
@ -805,6 +808,8 @@
|
|||
"group.role.owner": "拥有者",
|
||||
"group.tabs.all": "全部",
|
||||
"group.tabs.members": "成员",
|
||||
"group.tags.hint": "最多添加 3 个关键词,这些关键词将作为群组讨论的核心话题。",
|
||||
"group.tags.label": "标签",
|
||||
"group.upload_banner": "已上传照片",
|
||||
"groups.discover.popular.empty": "目前无法获取热门群组。请稍后再试。",
|
||||
"groups.discover.popular.show_more": "显示更多",
|
||||
|
@ -935,7 +940,7 @@
|
|||
"login_form.header": "登录",
|
||||
"manage_group.blocked_members": "已封禁成员",
|
||||
"manage_group.confirmation.copy": "复制链接",
|
||||
"manage_group.confirmation.info_1": "作为此群组的拥有者,你可以指派管理员,删除帖文等等。",
|
||||
"manage_group.confirmation.info_1": "作为此群组的拥有者,你可以分配管理员,删除帖文等等。",
|
||||
"manage_group.confirmation.info_2": "发布群组的第一条帖文,开始对话。",
|
||||
"manage_group.confirmation.info_3": "与朋友、家人和关注者分享您的新群组,以增加其成员数量。",
|
||||
"manage_group.confirmation.share": "分享此群组",
|
||||
|
@ -944,10 +949,10 @@
|
|||
"manage_group.delete_group": "删除群组",
|
||||
"manage_group.done": "完成",
|
||||
"manage_group.edit_group": "编辑群组",
|
||||
"manage_group.edit_success": "群组已编辑",
|
||||
"manage_group.fields.cannot_change_hint": "创建群组后此设置将无法更改。",
|
||||
"manage_group.fields.description_label": "描述",
|
||||
"manage_group.fields.description_placeholder": "描述",
|
||||
"manage_group.fields.hashtag_placeholder": "添加一个主题",
|
||||
"manage_group.fields.name_help": "创建群组后此设置将无法更改。",
|
||||
"manage_group.fields.name_label": "群组名称(必填)",
|
||||
"manage_group.fields.name_label_optional": "群组名称",
|
||||
|
@ -961,9 +966,8 @@
|
|||
"manage_group.privacy.private.label": "私有(需要群组所有者批准)",
|
||||
"manage_group.privacy.public.hint": "可发现。任何人都可以加入。",
|
||||
"manage_group.privacy.public.label": "公开",
|
||||
"manage_group.submit_success": "群组已创建",
|
||||
"manage_group.success": "群组已保存!",
|
||||
"manage_group.tagline": "群组根据共同的兴趣将您与他人联系起来。",
|
||||
"manage_group.update": "更新",
|
||||
"media_panel.empty_message": "未找到媒体。",
|
||||
"media_panel.title": "媒体",
|
||||
"mfa.confirm.success_message": "多重身份认证(MFA)已成功启用",
|
||||
|
@ -1053,6 +1057,8 @@
|
|||
"notification.favourite": "{name} 点赞了您的帖文",
|
||||
"notification.follow": "{name} 开始关注您",
|
||||
"notification.follow_request": "{name} 请求关注您",
|
||||
"notification.group_favourite": "{name} 点赞了您的群组帖文",
|
||||
"notification.group_reblog": "{name} 转发了您的群组帖文",
|
||||
"notification.mention": "{name} 提及了您",
|
||||
"notification.mentioned": "{name} 提及了您",
|
||||
"notification.move": "{name} 移动到了 {targetName}",
|
||||
|
@ -1456,8 +1462,9 @@
|
|||
"status.show_less_all": "减少这类帖文的展示",
|
||||
"status.show_more_all": "增加这类帖文的展示",
|
||||
"status.show_original": "显示原文本",
|
||||
"status.title": "@{username} 的帖文",
|
||||
"status.title": "帖文详情",
|
||||
"status.title_direct": "私信",
|
||||
"status.title_group": "群组帖文详情",
|
||||
"status.translate": "翻译",
|
||||
"status.translated_from_with": "使用 {provider} 从 {lang} 翻译而来",
|
||||
"status.unbookmark": "移除书签",
|
||||
|
|
|
@ -33,6 +33,7 @@ export const GroupRecord = ImmutableRecord({
|
|||
members_count: 0,
|
||||
note: '',
|
||||
statuses_visibility: 'public',
|
||||
tags: [],
|
||||
uri: '',
|
||||
url: '',
|
||||
|
||||
|
|
|
@ -22,6 +22,7 @@ import { Tabs } from '../components/ui';
|
|||
const messages = defineMessages({
|
||||
all: { id: 'group.tabs.all', defaultMessage: 'All' },
|
||||
members: { id: 'group.tabs.members', defaultMessage: 'Members' },
|
||||
media: { id: 'group.tabs.media', defaultMessage: 'Media' },
|
||||
});
|
||||
|
||||
interface IGroupPage {
|
||||
|
@ -84,6 +85,11 @@ const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
|
|||
name: '/groups/:id/members',
|
||||
count: pending.length,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.media),
|
||||
to: `/groups/${group?.id}/media`,
|
||||
name: '/groups/:id/media',
|
||||
},
|
||||
];
|
||||
|
||||
const renderChildren = () => {
|
||||
|
@ -99,7 +105,7 @@ const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
|
|||
return (
|
||||
<>
|
||||
<Layout.Main>
|
||||
<Column label={group ? group.display_name : ''} withHeader={false}>
|
||||
<Column size='lg' label={group ? group.display_name : ''} withHeader={false}>
|
||||
<GroupHeader group={group} />
|
||||
|
||||
<Tabs
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Layout } from 'soapbox/components/ui';
|
||||
import LinkFooter from 'soapbox/features/ui/components/link-footer';
|
||||
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
|
||||
import { MyGroupsPanel, NewGroupPanel } from 'soapbox/features/ui/util/async-components';
|
||||
|
||||
interface IGroupsPage {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** Page to display groups. */
|
||||
const ManageGroupsPage: React.FC<IGroupsPage> = ({ children }) => (
|
||||
<>
|
||||
<Layout.Main>
|
||||
{children}
|
||||
</Layout.Main>
|
||||
|
||||
<Layout.Aside>
|
||||
<BundleContainer fetchComponent={NewGroupPanel}>
|
||||
{Component => <Component />}
|
||||
</BundleContainer>
|
||||
<BundleContainer fetchComponent={MyGroupsPanel}>
|
||||
{Component => <Component />}
|
||||
</BundleContainer>
|
||||
|
||||
<LinkFooter />
|
||||
</Layout.Aside>
|
||||
</>
|
||||
);
|
||||
|
||||
export default ManageGroupsPage;
|
|
@ -97,7 +97,7 @@ const ProfilePage: React.FC<IProfilePage> = ({ params, children }) => {
|
|||
return (
|
||||
<>
|
||||
<Layout.Main>
|
||||
<Column label={account ? `@${getAcct(account, displayFqn)}` : ''} withHeader={false}>
|
||||
<Column size='lg' label={account ? `@${getAcct(account, displayFqn)}` : ''} withHeader={false}>
|
||||
<div className='space-y-4'>
|
||||
<Header account={account} />
|
||||
|
||||
|
|
|
@ -1,67 +0,0 @@
|
|||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
|
||||
import { getNextLink } from 'soapbox/api';
|
||||
import { useApi, useFeatures } from 'soapbox/hooks';
|
||||
import { normalizeGroup } from 'soapbox/normalizers';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
import { flattenPages, PaginatedResult } from 'soapbox/utils/queries';
|
||||
|
||||
const GroupSearchKeys = {
|
||||
search: (query?: string) => query ? ['groups', 'search', query] : ['groups', 'search'] as const,
|
||||
};
|
||||
|
||||
type PageParam = {
|
||||
link: string
|
||||
}
|
||||
|
||||
const useGroupSearch = (search?: string) => {
|
||||
const api = useApi();
|
||||
const features = useFeatures();
|
||||
|
||||
const getSearchResults = async (pageParam: PageParam): Promise<PaginatedResult<Group>> => {
|
||||
const nextPageLink = pageParam?.link;
|
||||
const uri = nextPageLink || '/api/v1/groups/search';
|
||||
const response = await api.get<Group[]>(uri, {
|
||||
params: search ? {
|
||||
q: search,
|
||||
} : undefined,
|
||||
});
|
||||
const { data } = response;
|
||||
|
||||
const link = getNextLink(response);
|
||||
const hasMore = !!link;
|
||||
const result = data.map(normalizeGroup);
|
||||
|
||||
return {
|
||||
result,
|
||||
hasMore,
|
||||
link,
|
||||
};
|
||||
};
|
||||
|
||||
const queryInfo = useInfiniteQuery(
|
||||
GroupSearchKeys.search(search),
|
||||
({ pageParam }) => getSearchResults(pageParam),
|
||||
{
|
||||
keepPreviousData: true,
|
||||
enabled: features.groups && !!search,
|
||||
getNextPageParam: (config) => {
|
||||
if (config.hasMore) {
|
||||
return { link: config.link };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const data = flattenPages(queryInfo.data);
|
||||
|
||||
return {
|
||||
...queryInfo,
|
||||
groups: data || [],
|
||||
};
|
||||
};
|
||||
|
||||
export {
|
||||
useGroupSearch,
|
||||
};
|
|
@ -0,0 +1,9 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
const groupTagSchema = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
type GroupTag = z.infer<typeof groupTagSchema>;
|
||||
|
||||
export { groupTagSchema, type GroupTag };
|
|
@ -6,6 +6,7 @@ import { unescapeHTML } from 'soapbox/utils/html';
|
|||
|
||||
import { customEmojiSchema } from './custom-emoji';
|
||||
import { groupRelationshipSchema } from './group-relationship';
|
||||
import { groupTagSchema } from './group-tag';
|
||||
import { filteredArray, makeCustomEmojiMap } from './utils';
|
||||
|
||||
const avatarMissing = require('assets/images/avatar-missing.png');
|
||||
|
@ -28,6 +29,7 @@ const groupSchema = z.object({
|
|||
note: z.string().transform(note => note === '<p></p>' ? '' : note).catch(''),
|
||||
relationship: groupRelationshipSchema.nullable().catch(null), // Dummy field to be overwritten later
|
||||
statuses_visibility: z.string().catch('public'),
|
||||
tags: z.array(groupTagSchema).catch([]),
|
||||
uri: z.string().catch(''),
|
||||
url: z.string().catch(''),
|
||||
}).transform(group => {
|
||||
|
@ -46,4 +48,4 @@ const groupSchema = z.object({
|
|||
|
||||
type Group = z.infer<typeof groupSchema>;
|
||||
|
||||
export { groupSchema, Group };
|
||||
export { groupSchema, type Group };
|
|
@ -0,0 +1,10 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { normalizeStatus } from 'soapbox/normalizers';
|
||||
import { toSchema } from 'soapbox/utils/normalizers';
|
||||
|
||||
const statusSchema = toSchema(normalizeStatus);
|
||||
|
||||
type Status = z.infer<typeof statusSchema>;
|
||||
|
||||
export { statusSchema, type Status };
|
|
@ -14,6 +14,7 @@ interface IToastOptions {
|
|||
actionLink?: string
|
||||
actionLabel?: ToastText
|
||||
duration?: number
|
||||
summary?: string
|
||||
}
|
||||
|
||||
const DEFAULT_DURATION = 4000;
|
||||
|
|
|
@ -529,6 +529,11 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
*/
|
||||
groups: v.build === UNRELEASED,
|
||||
|
||||
/**
|
||||
* Cap # of Group Admins to 5
|
||||
*/
|
||||
groupsAdminMax: v.software === TRUTHSOCIAL,
|
||||
|
||||
/**
|
||||
* Can see trending/suggested Groups.
|
||||
*/
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import z from 'zod';
|
||||
|
||||
/** Use new value only if old value is undefined */
|
||||
export const mergeDefined = (oldVal: any, newVal: any) => oldVal === undefined ? newVal : oldVal;
|
||||
|
||||
|
@ -10,3 +12,18 @@ export const makeEmojiMap = (emojis: any) => emojis.reduce((obj: any, emoji: any
|
|||
export const normalizeId = (id: any): string | null => {
|
||||
return typeof id === 'string' ? id : null;
|
||||
};
|
||||
|
||||
export type Normalizer<V, R> = (value: V) => R;
|
||||
|
||||
/**
|
||||
* Allows using any legacy normalizer function as a zod schema.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const statusSchema = toSchema(normalizeStatus);
|
||||
* statusSchema.parse(status);
|
||||
* ```
|
||||
*/
|
||||
export const toSchema = <V, R>(normalizer: Normalizer<V, R>) => {
|
||||
return z.custom<V>().transform<R>(normalizer);
|
||||
};
|
|
@ -5,6 +5,8 @@ const NOTIFICATION_TYPES = [
|
|||
'mention',
|
||||
'reblog',
|
||||
'favourite',
|
||||
'group_favourite',
|
||||
'group_reblog',
|
||||
'poll',
|
||||
'status',
|
||||
'move',
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
.thread {
|
||||
@apply bg-white dark:bg-primary-900 p-4 shadow-xl dark:shadow-none sm:p-6 sm:rounded-xl;
|
||||
@apply bg-white dark:bg-primary-900 sm:rounded-xl;
|
||||
|
||||
&__status {
|
||||
@apply relative pb-4;
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
|
||||
[column-type='filled'] .status__wrapper,
|
||||
[column-type='filled'] .status-placeholder {
|
||||
@apply bg-transparent dark:bg-transparent rounded-none shadow-none p-4;
|
||||
@apply bg-transparent dark:bg-transparent rounded-none shadow-none;
|
||||
}
|
||||
|
||||
.status-check-box {
|
||||
|
|
|
@ -48,7 +48,7 @@
|
|||
"@babel/preset-typescript": "^7.18.6",
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@emoji-mart/data": "^1.1.2",
|
||||
"@floating-ui/react": "^0.21.0",
|
||||
"@floating-ui/react": "^0.23.0",
|
||||
"@fontsource/inter": "^4.5.1",
|
||||
"@fontsource/roboto-mono": "^4.5.8",
|
||||
"@gamestdio/websocket": "^0.3.2",
|
||||
|
|
|
@ -1742,10 +1742,10 @@
|
|||
dependencies:
|
||||
"@floating-ui/dom" "^1.2.1"
|
||||
|
||||
"@floating-ui/react@^0.21.0":
|
||||
version "0.21.1"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.21.1.tgz#47cafdff0c79f5aa1067398ee06ea2144d22ea7a"
|
||||
integrity sha512-ojjsU/rvWEyNDproy1yQW5EDXJnDip8DXpSRh+hogPgZWEp0Y/2UBPxL3yoa53BDYsL+dqJY0osl9r0Jes3eeg==
|
||||
"@floating-ui/react@^0.23.0":
|
||||
version "0.23.0"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.23.0.tgz#8b548235ac4478537757c90a66a3bac9068e29d8"
|
||||
integrity sha512-Id9zTLSjHtcCjBQm0Stc/fRUBGrnHurL/a1HrtQg8LvL6Ciw9KHma2WT++F17kEfhsPkA0UHYxmp+ijmAy0TCw==
|
||||
dependencies:
|
||||
"@floating-ui/react-dom" "^1.3.0"
|
||||
aria-hidden "^1.1.3"
|
||||
|
|
Loading…
Reference in New Issue