diff --git a/CHANGELOG.md b/CHANGELOG.md index ee32cbb92..b6bc03ee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Posts: truncate Nostr pubkeys in reply mentions. - Posts: upgraded emoji picker component. +- Posts: improved design of threads. - UI: unified design of "approve" and "reject" buttons in follow requests and waitlist. +- UI: added sticky column header. ### Fixed - Posts: fixed emojis being cut off in reactions modal. diff --git a/app/soapbox/components/status-list.tsx b/app/soapbox/components/status-list.tsx index 30ead15d7..43666e91e 100644 --- a/app/soapbox/components/status-list.tsx +++ b/app/soapbox/components/status-list.tsx @@ -139,6 +139,7 @@ const StatusList: React.FC = ({ onMoveDown={handleMoveDown} contextType={timelineId} showGroup={showGroup} + variant={divideType === 'border' ? 'slim' : 'rounded'} /> ); }; @@ -172,6 +173,7 @@ const StatusList: React.FC = ({ onMoveDown={handleMoveDown} contextType={timelineId} showGroup={showGroup} + variant={divideType === 'border' ? 'slim' : 'default'} /> )); }; @@ -245,7 +247,7 @@ const StatusList: React.FC = ({ isLoading={isLoading} showLoading={isLoading && statusIds.size === 0} onLoadMore={handleLoadOlder} - placeholderComponent={PlaceholderStatus} + placeholderComponent={() => } placeholderCount={20} ref={node} className={clsx('divide-y divide-solid divide-gray-200 dark:divide-gray-800', { diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index 65291a420..752e73636 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -50,7 +50,7 @@ export interface IStatus { featured?: boolean hideActionBar?: boolean hoverable?: boolean - variant?: 'default' | 'rounded' + variant?: 'default' | 'rounded' | 'slim' showGroup?: boolean accountAction?: React.ReactElement } diff --git a/app/soapbox/components/ui/card/card.tsx b/app/soapbox/components/ui/card/card.tsx index aedf3e132..4b8d9799d 100644 --- a/app/soapbox/components/ui/card/card.tsx +++ b/app/soapbox/components/ui/card/card.tsx @@ -16,11 +16,13 @@ const messages = defineMessages({ back: { id: 'card.back.label', defaultMessage: 'Back' }, }); +export type CardSizes = keyof typeof sizes + interface ICard { /** The type of card. */ - variant?: 'default' | 'rounded' + variant?: 'default' | 'rounded' | 'slim' /** Card size preset. */ - size?: keyof typeof sizes + size?: CardSizes /** Extra classnames for the
element. */ className?: string /** Elements inside the card. */ @@ -33,8 +35,9 @@ const Card = React.forwardRef(({ children, variant = 'def ref={ref} {...filteredProps} className={clsx({ - 'bg-white dark:bg-primary-900 text-gray-900 dark:text-gray-100 shadow-lg dark:shadow-none overflow-hidden': variant === 'rounded', + 'bg-white dark:bg-primary-900 text-gray-900 dark:text-gray-100 shadow-lg dark:shadow-none': variant === 'rounded', [sizes[size]]: variant === 'rounded', + 'py-4': variant === 'slim', }, className)} > {children} @@ -72,7 +75,7 @@ const CardHeader: React.FC = ({ className, children, backHref, onBa }; return ( - + {renderBackButton()} {children} diff --git a/app/soapbox/components/ui/column/column.tsx b/app/soapbox/components/ui/column/column.tsx index d6cadec77..8813b107b 100644 --- a/app/soapbox/components/ui/column/column.tsx +++ b/app/soapbox/components/ui/column/column.tsx @@ -1,11 +1,12 @@ import clsx from 'clsx'; -import React from 'react'; +import throttle from 'lodash/throttle'; +import React, { useCallback, useEffect, useState } from 'react'; import { useHistory } from 'react-router-dom'; import Helmet from 'soapbox/components/helmet'; import { useSoapboxConfig } from 'soapbox/hooks'; -import { Card, CardBody, CardHeader, CardTitle } from '../card/card'; +import { Card, CardBody, CardHeader, CardTitle, type CardSizes } from '../card/card'; type IColumnHeader = Pick; @@ -54,13 +55,29 @@ export interface IColumn { ref?: React.Ref /** Children to display in the column. */ children?: React.ReactNode + /** Action for the ColumnHeader, displayed at the end. */ action?: React.ReactNode + /** Column size, inherited from Card. */ + size?: CardSizes } /** A backdrop for the main section of the UI. */ const Column: React.FC = React.forwardRef((props, ref: React.ForwardedRef): JSX.Element => { - const { backHref, children, label, transparent = false, withHeader = true, className, action } = props; + const { backHref, children, label, transparent = false, withHeader = true, className, action, size } = props; const soapboxConfig = useSoapboxConfig(); + const [isScrolled, setIsScrolled] = useState(false); + + const handleScroll = useCallback(throttle(() => { + setIsScrolled(window.pageYOffset > 32); + }, 50), []); + + useEffect(() => { + window.addEventListener('scroll', handleScroll); + + return () => { + window.removeEventListener('scroll', handleScroll); + }; + }, []); return (
@@ -76,12 +93,18 @@ const Column: React.FC = React.forwardRef((props, ref: React.ForwardedR )} - + {withHeader && ( )} diff --git a/app/soapbox/components/ui/streamfield/streamfield.tsx b/app/soapbox/components/ui/streamfield/streamfield.tsx index 5c436e70b..bb2ca0ba5 100644 --- a/app/soapbox/components/ui/streamfield/streamfield.tsx +++ b/app/soapbox/components/ui/streamfield/streamfield.tsx @@ -69,14 +69,14 @@ const Streamfield: React.FC = ({ {(values.length > 0) && ( - + {values.map((value, i) => value?._destroy ? null : ( {values.length > minItems && onRemoveItem && ( onRemoveItem(i)} title={intl.formatMessage(messages.remove)} @@ -87,11 +87,9 @@ const Streamfield: React.FC = ({ )} - {onAddItem && ( + {(onAddItem && (values.length < maxItems)) && ( +
- {/* Dismiss Button */} -
- -
-
+ {summary ? ( + {summary} + ) : null} +
); }; diff --git a/app/soapbox/components/ui/toggle/toggle.tsx b/app/soapbox/components/ui/toggle/toggle.tsx index 34651e98d..0311da8ac 100644 --- a/app/soapbox/components/ui/toggle/toggle.tsx +++ b/app/soapbox/components/ui/toggle/toggle.tsx @@ -26,6 +26,7 @@ const Toggle: React.FC = ({ id, size = 'md', name, checked, onChange, r 'cursor-default': disabled, })} onClick={handleClick} + type='button' >
{ const now = new Date(); - const action = entitiesFetchSuccess(entities, 'TestEntity', 'thingies', { + const action = entitiesFetchSuccess(entities, 'TestEntity', 'thingies', 'end', { next: undefined, prev: undefined, totalCount: 2, diff --git a/app/soapbox/entity-store/actions.ts b/app/soapbox/entity-store/actions.ts index c3ba25559..bb96255c6 100644 --- a/app/soapbox/entity-store/actions.ts +++ b/app/soapbox/entity-store/actions.ts @@ -1,4 +1,4 @@ -import type { Entity, EntityListState } from './types'; +import type { Entity, EntityListState, ImportPosition } from './types'; const ENTITIES_IMPORT = 'ENTITIES_IMPORT' as const; const ENTITIES_DELETE = 'ENTITIES_DELETE' as const; @@ -10,12 +10,13 @@ const ENTITIES_FETCH_FAIL = 'ENTITIES_FETCH_FAIL' as const; const ENTITIES_INVALIDATE_LIST = 'ENTITIES_INVALIDATE_LIST' as const; /** Action to import entities into the cache. */ -function importEntities(entities: Entity[], entityType: string, listKey?: string) { +function importEntities(entities: Entity[], entityType: string, listKey?: string, pos?: ImportPosition) { return { type: ENTITIES_IMPORT, entityType, entities, listKey, + pos, }; } @@ -62,6 +63,7 @@ function entitiesFetchSuccess( entities: Entity[], entityType: string, listKey?: string, + pos?: ImportPosition, newState?: EntityListState, overwrite = false, ) { @@ -70,6 +72,7 @@ function entitiesFetchSuccess( entityType, entities, listKey, + pos, newState, overwrite, }; diff --git a/app/soapbox/entity-store/entities.ts b/app/soapbox/entity-store/entities.ts index 7f9f84e2a..719cc7f1c 100644 --- a/app/soapbox/entity-store/entities.ts +++ b/app/soapbox/entity-store/entities.ts @@ -3,5 +3,6 @@ export enum Entities { GROUPS = 'Groups', GROUP_RELATIONSHIPS = 'GroupRelationships', GROUP_MEMBERSHIPS = 'GroupMemberships', - RELATIONSHIPS = 'Relationships' + RELATIONSHIPS = 'Relationships', + STATUSES = 'Statuses', } \ No newline at end of file diff --git a/app/soapbox/entity-store/hooks/useCreateEntity.ts b/app/soapbox/entity-store/hooks/useCreateEntity.ts index 31299344e..6b4aaff73 100644 --- a/app/soapbox/entity-store/hooks/useCreateEntity.ts +++ b/app/soapbox/entity-store/hooks/useCreateEntity.ts @@ -30,7 +30,7 @@ function useCreateEntity( const entity = schema.parse(result.data); // TODO: optimistic updating - dispatch(importEntities([entity], entityType, listKey)); + dispatch(importEntities([entity], entityType, listKey, 'start')); if (callbacks.onSuccess) { callbacks.onSuccess(entity); diff --git a/app/soapbox/entity-store/hooks/useEntities.ts b/app/soapbox/entity-store/hooks/useEntities.ts index c1bf47e84..cd413f487 100644 --- a/app/soapbox/entity-store/hooks/useEntities.ts +++ b/app/soapbox/entity-store/hooks/useEntities.ts @@ -54,7 +54,7 @@ function useEntities( const next = useListState(path, 'next'); const prev = useListState(path, 'prev'); - const fetchPage = async(req: EntityFn, overwrite = false): Promise => { + const fetchPage = async(req: EntityFn, pos: 'start' | 'end', overwrite = false): Promise => { // Get `isFetching` state from the store again to prevent race conditions. const isFetching = selectListState(getState(), path, 'fetching'); if (isFetching) return; @@ -65,11 +65,12 @@ function useEntities( const schema = opts.schema || z.custom(); const entities = filteredArray(schema).parse(response.data); const parsedCount = realNumberSchema.safeParse(response.headers['x-total-count']); + const totalCount = parsedCount.success ? parsedCount.data : undefined; - dispatch(entitiesFetchSuccess(entities, entityType, listKey, { + dispatch(entitiesFetchSuccess(entities, entityType, listKey, pos, { next: getNextLink(response), prev: getPrevLink(response), - totalCount: parsedCount.success ? parsedCount.data : undefined, + totalCount: Number(totalCount) >= entities.length ? totalCount : undefined, fetching: false, fetched: true, error: null, @@ -82,18 +83,18 @@ function useEntities( }; const fetchEntities = async(): Promise => { - await fetchPage(entityFn, true); + await fetchPage(entityFn, 'end', true); }; const fetchNextPage = async(): Promise => { if (next) { - await fetchPage(() => api.get(next)); + await fetchPage(() => api.get(next), 'end'); } }; const fetchPreviousPage = async(): Promise => { if (prev) { - await fetchPage(() => api.get(prev)); + await fetchPage(() => api.get(prev), 'start'); } }; diff --git a/app/soapbox/entity-store/reducer.ts b/app/soapbox/entity-store/reducer.ts index b71fb812f..ef7b604d9 100644 --- a/app/soapbox/entity-store/reducer.ts +++ b/app/soapbox/entity-store/reducer.ts @@ -14,7 +14,7 @@ import { import { createCache, createList, updateStore, updateList } from './utils'; import type { DeleteEntitiesOpts } from './actions'; -import type { Entity, EntityCache, EntityListState } from './types'; +import type { Entity, EntityCache, EntityListState, ImportPosition } from './types'; enableMapSet(); @@ -29,6 +29,7 @@ const importEntities = ( entityType: string, entities: Entity[], listKey?: string, + pos?: ImportPosition, newState?: EntityListState, overwrite = false, ): State => { @@ -43,7 +44,7 @@ const importEntities = ( list.ids = new Set(); } - list = updateList(list, entities); + list = updateList(list, entities, pos); if (newState) { list.state = newState; @@ -159,7 +160,7 @@ const invalidateEntityList = (state: State, entityType: string, listKey: string) function reducer(state: Readonly = {}, action: EntityAction): State { switch (action.type) { case ENTITIES_IMPORT: - return importEntities(state, action.entityType, action.entities, action.listKey); + return importEntities(state, action.entityType, action.entities, action.listKey, action.pos); case ENTITIES_DELETE: return deleteEntities(state, action.entityType, action.ids, action.opts); case ENTITIES_DISMISS: @@ -167,7 +168,7 @@ function reducer(state: Readonly = {}, action: EntityAction): State { case ENTITIES_INCREMENT: return incrementEntities(state, action.entityType, action.listKey, action.diff); case ENTITIES_FETCH_SUCCESS: - return importEntities(state, action.entityType, action.entities, action.listKey, action.newState, action.overwrite); + return importEntities(state, action.entityType, action.entities, action.listKey, action.pos, action.newState, action.overwrite); case ENTITIES_FETCH_REQUEST: return setFetching(state, action.entityType, action.listKey, true); case ENTITIES_FETCH_FAIL: diff --git a/app/soapbox/entity-store/types.ts b/app/soapbox/entity-store/types.ts index 006b13ba2..5fff2f474 100644 --- a/app/soapbox/entity-store/types.ts +++ b/app/soapbox/entity-store/types.ts @@ -47,10 +47,14 @@ interface EntityCache { } } +/** Whether to import items at the start or end of the list. */ +type ImportPosition = 'start' | 'end' + export { Entity, EntityStore, EntityList, EntityListState, EntityCache, + ImportPosition, }; \ No newline at end of file diff --git a/app/soapbox/entity-store/utils.ts b/app/soapbox/entity-store/utils.ts index 3f65f1ee0..58d54465a 100644 --- a/app/soapbox/entity-store/utils.ts +++ b/app/soapbox/entity-store/utils.ts @@ -1,4 +1,4 @@ -import type { Entity, EntityStore, EntityList, EntityCache, EntityListState } from './types'; +import type { Entity, EntityStore, EntityList, EntityCache, EntityListState, ImportPosition } from './types'; /** Insert the entities into the store. */ const updateStore = (store: EntityStore, entities: Entity[]): EntityStore => { @@ -9,9 +9,10 @@ const updateStore = (store: EntityStore, entities: Entity[]): EntityStore => { }; /** Update the list with new entity IDs. */ -const updateList = (list: EntityList, entities: Entity[]): EntityList => { +const updateList = (list: EntityList, entities: Entity[], pos: ImportPosition = 'end'): EntityList => { const newIds = entities.map(entity => entity.id); - const ids = new Set([...newIds, ...Array.from(list.ids)]); + const oldIds = Array.from(list.ids); + const ids = new Set(pos === 'start' ? [...newIds, ...oldIds] : [...oldIds, ...newIds]); if (typeof list.state.totalCount === 'number') { const sizeDiff = ids.size - list.ids.size; diff --git a/app/soapbox/features/account/components/header.tsx b/app/soapbox/features/account/components/header.tsx index ac4fdd66e..4a589a328 100644 --- a/app/soapbox/features/account/components/header.tsx +++ b/app/soapbox/features/account/components/header.tsx @@ -105,7 +105,7 @@ const Header: React.FC = ({ account }) => { if (!account) { return ( -
+
@@ -608,7 +608,7 @@ const Header: React.FC = ({ account }) => { const menu = makeMenu(); return ( -
+
{(account.moved && typeof account.moved === 'object') && ( )} diff --git a/app/soapbox/features/event/event-discussion.tsx b/app/soapbox/features/event/event-discussion.tsx index 54a539a8a..3c96c73b8 100644 --- a/app/soapbox/features/event/event-discussion.tsx +++ b/app/soapbox/features/event/event-discussion.tsx @@ -184,7 +184,7 @@ const EventDiscussion: React.FC = (props) => { ref={scroller} hasMore={!!next} onLoadMore={handleLoadMore} - placeholderComponent={() => } + placeholderComponent={() => } initialTopMostItemIndex={0} emptyMessage={} > diff --git a/app/soapbox/features/group/components/group-header.tsx b/app/soapbox/features/group/components/group-header.tsx index 713c64c86..a49d87fe7 100644 --- a/app/soapbox/features/group/components/group-header.tsx +++ b/app/soapbox/features/group/components/group-header.tsx @@ -32,7 +32,7 @@ const GroupHeader: React.FC = ({ group }) => { if (!group) { return ( -
+
@@ -105,7 +105,7 @@ const GroupHeader: React.FC = ({ group }) => { }; return ( -
+
{renderHeader()} diff --git a/app/soapbox/features/group/components/group-member-list-item.tsx b/app/soapbox/features/group/components/group-member-list-item.tsx index 4fa3ccb7a..c88f4e58d 100644 --- a/app/soapbox/features/group/components/group-member-list-item.tsx +++ b/app/soapbox/features/group/components/group-member-list-item.tsx @@ -15,10 +15,14 @@ import { useAccount, useBlockGroupMember, useDemoteGroupMember, usePromoteGroupM import { GroupRoles } from 'soapbox/schemas/group-member'; import toast from 'soapbox/toast'; +import { MAX_ADMIN_COUNT } from '../group-members'; + import type { Menu as IMenu } from 'soapbox/components/dropdown-menu'; import type { Group, GroupMember } from 'soapbox/types/entities'; const messages = defineMessages({ + adminLimitTitle: { id: 'group.member.admin.limit.title', defaultMessage: 'Admin limit reached' }, + adminLimitSummary: { id: 'group.member.admin.limit.summary', defaultMessage: 'You can assign up to {count} admins for the group at this time.' }, blockConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Ban' }, blockFromGroupHeading: { id: 'confirmations.block_from_group.heading', defaultMessage: 'Ban From Group' }, blockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to ban @{name} from the group?' }, @@ -39,10 +43,11 @@ const messages = defineMessages({ interface IGroupMemberListItem { member: GroupMember group: Group + canPromoteToAdmin: boolean } const GroupMemberListItem = (props: IGroupMemberListItem) => { - const { member, group } = props; + const { canPromoteToAdmin, member, group } = props; const dispatch = useAppDispatch(); const features = useFeatures(); @@ -90,6 +95,13 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => { }; const handleAdminAssignment = () => { + if (!canPromoteToAdmin) { + toast.error(intl.formatMessage(messages.adminLimitTitle), { + summary: intl.formatMessage(messages.adminLimitSummary, { count: MAX_ADMIN_COUNT }), + }); + return; + } + dispatch(openModal('CONFIRM', { heading: intl.formatMessage(messages.promoteConfirm), message: intl.formatMessage(messages.promoteConfirmMessage, { name: account?.username }), diff --git a/app/soapbox/features/group/components/group-tags-field.tsx b/app/soapbox/features/group/components/group-tags-field.tsx new file mode 100644 index 000000000..e4592a6e4 --- /dev/null +++ b/app/soapbox/features/group/components/group-tags-field.tsx @@ -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 = ({ tags, onChange, onAddItem, onRemoveItem, maxItems = 3 }) => { + return ( + } + hint={} + component={HashtagField} + values={tags} + onChange={onChange} + onAddItem={onAddItem} + onRemoveItem={onRemoveItem} + maxItems={maxItems} + /> + ); +}; + +interface IHashtagField { + value: string + onChange: (value: string) => void +} + +const HashtagField: React.FC = ({ value, onChange }) => { + const intl = useIntl(); + + const handleChange: React.ChangeEventHandler = ({ target }) => { + onChange(target.value); + }; + + return ( + + ); +}; + +export default GroupTagsField; \ No newline at end of file diff --git a/app/soapbox/features/group/edit-group.tsx b/app/soapbox/features/group/edit-group.tsx index 23ff9ed98..1e8024f6f 100644 --- a/app/soapbox/features/group/edit-group.tsx +++ b/app/soapbox/features/group/edit-group.tsx @@ -1,8 +1,7 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import Icon from 'soapbox/components/icon'; -import { Button, Column, Form, FormActions, FormGroup, Input, Spinner, Textarea } from 'soapbox/components/ui'; +import { Button, Column, Form, FormActions, FormGroup, Icon, Input, Spinner, Textarea } from 'soapbox/components/ui'; import { useAppSelector, useInstance } from 'soapbox/hooks'; import { useGroup, useUpdateGroup } from 'soapbox/hooks/api'; import { useImageField, useTextField } from 'soapbox/hooks/forms'; @@ -10,6 +9,7 @@ import { isDefaultAvatar, isDefaultHeader } from 'soapbox/utils/accounts'; import AvatarPicker from './components/group-avatar-picker'; import HeaderPicker from './components/group-header-picker'; +import GroupTagsField from './components/group-tags-field'; import type { List as ImmutableList } from 'immutable'; @@ -20,6 +20,7 @@ const messages = defineMessages({ heading: { id: 'navigation_bar.edit_group', defaultMessage: 'Edit Group' }, groupNamePlaceholder: { id: 'manage_group.fields.name_placeholder', defaultMessage: 'Group Name' }, groupDescriptionPlaceholder: { id: 'manage_group.fields.description_placeholder', defaultMessage: 'Description' }, + success: { id: 'manage_group.success', defaultMessage: 'Group saved!' }, }); interface IEditGroup { @@ -36,6 +37,7 @@ const EditGroup: React.FC = ({ params: { id: groupId } }) => { const { updateGroup } = useUpdateGroup(groupId); const [isSubmitting, setIsSubmitting] = useState(false); + const [tags, setTags] = useState(['']); const avatar = useImageField({ maxPixels: 400 * 400, preview: nonDefaultAvatar(group?.avatar) }); const header = useImageField({ maxPixels: 1920 * 1080, preview: nonDefaultHeader(group?.header) }); @@ -58,11 +60,28 @@ const EditGroup: React.FC = ({ params: { id: groupId } }) => { note: note.value, avatar: avatar.file, header: header.file, + tags, }); setIsSubmitting(false); } + const handleAddTag = () => { + setTags([...tags, '']); + }; + + const handleRemoveTag = (i: number) => { + const newTags = [...tags]; + newTags.splice(i, 1); + setTags(newTags); + }; + + useEffect(() => { + if (group) { + setTags(group.tags.map((t) => t.name)); + } + }, [group?.id]); + if (isLoading) { return ; } @@ -98,6 +117,15 @@ const EditGroup: React.FC = ({ params: { id: groupId } }) => { /> +
+ +
+