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