Merge remote-tracking branch 'soapbox/develop' into lexical

This commit is contained in:
marcin mikołajczak 2023-04-11 20:36:58 +02:00
commit 2549e72843
66 changed files with 1951 additions and 1145 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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}
/> />
)} )}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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} />
)} )}

View File

@ -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.' />}
> >

View File

@ -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()}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) => {

View File

@ -76,4 +76,4 @@ function useGroupRelationships(groupIds: string[]) {
}; };
} }
export { useGroup, useGroups, useGroupRelationships }; export { useGroup, useGroups, useGroupRelationship, useGroupRelationships };

View File

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

View File

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

View File

@ -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": "تم تأكيد إعدادات المصادقة المتعددة",

View File

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

View File

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

View File

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

View File

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

View File

@ -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": "სრულ ეკრანზე ჩვენების გათიშვა",

View File

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

View File

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

View File

@ -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": "移除书签",

View File

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

View File

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

View File

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

View File

@ -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} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,6 +5,8 @@ const NOTIFICATION_TYPES = [
'mention', 'mention',
'reblog', 'reblog',
'favourite', 'favourite',
'group_favourite',
'group_reblog',
'poll', 'poll',
'status', 'status',
'move', 'move',

View File

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

View File

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

View File

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

View File

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