diff --git a/app/soapbox/actions/statuses.ts b/app/soapbox/actions/statuses.ts index b14108de2..ab6a855ee 100644 --- a/app/soapbox/actions/statuses.ts +++ b/app/soapbox/actions/statuses.ts @@ -5,6 +5,7 @@ import { shouldHaveCard } from 'soapbox/utils/status'; import api, { getNextLink } from '../api'; import { setComposeToStatus } from './compose'; +import { fetchGroupRelationships } from './groups'; import { importFetchedStatus, importFetchedStatuses } from './importer'; import { openModal } from './modals'; import { deleteFromTimelines } from './timelines'; @@ -124,6 +125,9 @@ const fetchStatus = (id: string) => { return api(getState).get(`/api/v1/statuses/${id}`).then(({ data: status }) => { dispatch(importFetchedStatus(status)); + if (status.group) { + dispatch(fetchGroupRelationships([status.group.id])); + } dispatch({ type: STATUS_FETCH_SUCCESS, status, skipLoading }); return status; }).catch(error => { diff --git a/app/soapbox/actions/timelines.ts b/app/soapbox/actions/timelines.ts index 7ae023338..cafaa6aa5 100644 --- a/app/soapbox/actions/timelines.ts +++ b/app/soapbox/actions/timelines.ts @@ -4,7 +4,7 @@ import { getSettings } from 'soapbox/actions/settings'; import { normalizeStatus } from 'soapbox/normalizers'; import { shouldFilter } from 'soapbox/utils/timelines'; -import api, { getLinks } from '../api'; +import api, { getNextLink, getPrevLink } from '../api'; import { importFetchedStatus, importFetchedStatuses } from './importer'; @@ -139,7 +139,7 @@ const parseTags = (tags: Record = {}, mode: 'any' | 'all' | 'none }; const replaceHomeTimeline = ( - accountId: string | null, + accountId: string | undefined, { maxId }: Record = {}, done?: () => void, ) => (dispatch: AppDispatch, _getState: () => RootState) => { @@ -162,7 +162,12 @@ const expandTimeline = (timelineId: string, path: string, params: Record 0) { + if ( + !params.max_id && + !params.pinned && + (timeline.items || ImmutableOrderedSet()).size > 0 && + !path.includes('max_id=') + ) { params.since_id = timeline.getIn(['items', 0]); } @@ -171,9 +176,16 @@ const expandTimeline = (timelineId: string, path: string, params: Record { - const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); - dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore)); + dispatch(expandTimelineSuccess( + timelineId, + response.data, + getNextLink(response), + getPrevLink(response), + response.status === 206, + isLoadingRecent, + isLoadingMore, + )); done(); }).catch(error => { dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); @@ -181,9 +193,26 @@ const expandTimeline = (timelineId: string, path: string, params: Record = {}, done = noOp) => { - const endpoint = accountId ? `/api/v1/accounts/${accountId}/statuses` : '/api/v1/timelines/home'; - const params: any = { max_id: maxId }; +interface ExpandHomeTimelineOpts { + accountId?: string + maxId?: string + url?: string +} + +interface HomeTimelineParams { + max_id?: string + exclude_replies?: boolean + with_muted?: boolean +} + +const expandHomeTimeline = ({ url, accountId, maxId }: ExpandHomeTimelineOpts = {}, done = noOp) => { + const endpoint = url || (accountId ? `/api/v1/accounts/${accountId}/statuses` : '/api/v1/timelines/home'); + const params: HomeTimelineParams = {}; + + if (!url && maxId) { + params.max_id = maxId; + } + if (accountId) { params.exclude_replies = true; params.with_muted = true; @@ -237,11 +266,20 @@ const expandTimelineRequest = (timeline: string, isLoadingMore: boolean) => ({ skipLoading: !isLoadingMore, }); -const expandTimelineSuccess = (timeline: string, statuses: APIEntity[], next: string | null, partial: boolean, isLoadingRecent: boolean, isLoadingMore: boolean) => ({ +const expandTimelineSuccess = ( + timeline: string, + statuses: APIEntity[], + next: string | undefined, + prev: string | undefined, + partial: boolean, + isLoadingRecent: boolean, + isLoadingMore: boolean, +) => ({ type: TIMELINE_EXPAND_SUCCESS, timeline, statuses, next, + prev, partial, isLoadingRecent, skipLoading: !isLoadingMore, diff --git a/app/soapbox/components/modal-root.tsx b/app/soapbox/components/modal-root.tsx index 84c1252c9..2358a951f 100644 --- a/app/soapbox/components/modal-root.tsx +++ b/app/soapbox/components/modal-root.tsx @@ -181,7 +181,9 @@ const ModalRoot: React.FC = ({ children, onCancel, onClose, type }) }; const getSiblings = () => { - return Array(...(ref.current!.parentElement!.childNodes as any as ChildNode[])).filter(node => node !== ref.current); + return Array(...(ref.current!.parentElement!.childNodes as any as ChildNode[])) + .filter(node => (node as HTMLDivElement).id !== 'toaster') + .filter(node => node !== ref.current); }; useEffect(() => { diff --git a/app/soapbox/components/status-action-bar.tsx b/app/soapbox/components/status-action-bar.tsx index 2938e91e7..585cc32d9 100644 --- a/app/soapbox/components/status-action-bar.tsx +++ b/app/soapbox/components/status-action-bar.tsx @@ -536,7 +536,7 @@ const StatusActionBar: React.FC = ({ return menu; }; - const publicStatus = ['public', 'unlisted'].includes(status.visibility); + const publicStatus = ['public', 'unlisted', 'group'].includes(status.visibility); const replyCount = status.replies_count; const reblogCount = status.reblogs_count; @@ -609,7 +609,7 @@ const StatusActionBar: React.FC = ({ replyTitle = intl.formatMessage(messages.replyAll); } - const canShare = ('share' in navigator) && status.visibility === 'public'; + const canShare = ('share' in navigator) && (status.visibility === 'public' || status.visibility === 'group'); return ( diff --git a/app/soapbox/components/status-action-button.tsx b/app/soapbox/components/status-action-button.tsx index 39795fc7e..47b3c11b8 100644 --- a/app/soapbox/components/status-action-button.tsx +++ b/app/soapbox/components/status-action-button.tsx @@ -53,7 +53,7 @@ const StatusActionButton = React.forwardRef = React.ComponentType<{ value: T onChange: (value: T) => void + autoFocus: boolean }>; interface IStreamfield { @@ -72,7 +73,12 @@ const Streamfield: React.FC = ({ {values.map((value, i) => value?._destroy ? null : ( - + 0} + /> {values.length > minItems && onRemoveItem && ( > /** Text to display in the tooltip. */ text: string - /** Element to display the tooltip around. */ - children: React.ReactNode } -const centered = (triggerRect: any, tooltipRect: any) => { - const triggerCenter = triggerRect.left + triggerRect.width / 2; - const left = triggerCenter - tooltipRect.width / 2; - const maxLeft = window.innerWidth - tooltipRect.width - 2; - return { - left: Math.min(Math.max(2, left), maxLeft) + window.scrollX, - top: triggerRect.bottom + 8 + window.scrollY, - }; -}; +/** + * Tooltip + */ +const Tooltip: React.FC = (props) => { + const { children, text } = props; -/** Hoverable tooltip element. */ -const Tooltip: React.FC = ({ - children, - text, -}) => { - // get the props from useTooltip - const [trigger, tooltip] = useTooltip(); + const [isOpen, setIsOpen] = useState(false); - // destructure off what we need to position the triangle - const { isVisible, triggerRect } = tooltip; + const arrowRef = useRef(null); + + const { x, y, strategy, refs, context } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + placement: 'top', + middleware: [ + offset(6), + arrow({ + element: arrowRef, + }), + ], + }); + + const hover = useHover(context); + const { isMounted, styles } = useTransitionStyles(context, { + initial: { + opacity: 0, + transform: 'scale(0.8)', + }, + duration: { + open: 200, + close: 200, + }, + }); + + const { getReferenceProps, getFloatingProps } = useInteractions([ + hover, + ]); return ( - - {React.cloneElement(children as any, trigger)} + <> + {React.cloneElement(children, { + ref: refs.setReference, + ...getReferenceProps(), + })} - {isVisible && ( - // The Triangle. We position it relative to the trigger, not the popup - // so that collisions don't have a triangle pointing off to nowhere. - // Using a Portal may seem a little extreme, but we can keep the - // positioning logic simpler here instead of needing to consider - // the popup's position relative to the trigger and collisions - + {(isMounted) && ( +
- + className='pointer-events-none z-[100] whitespace-nowrap rounded bg-gray-800 px-2.5 py-1.5 text-xs font-medium text-gray-100 shadow dark:bg-gray-100 dark:text-gray-900' + {...getFloatingProps()} + > + {text} + + +
+
)} - -
+ ); }; -export default Tooltip; +export default Tooltip; \ No newline at end of file diff --git a/app/soapbox/containers/soapbox.tsx b/app/soapbox/containers/soapbox.tsx index fb6ce9481..75134b00d 100644 --- a/app/soapbox/containers/soapbox.tsx +++ b/app/soapbox/containers/soapbox.tsx @@ -191,7 +191,14 @@ const SoapboxMount = () => { - + +
+ +
diff --git a/app/soapbox/entity-store/entities.ts b/app/soapbox/entity-store/entities.ts index 719cc7f1c..9878cbbf2 100644 --- a/app/soapbox/entity-store/entities.ts +++ b/app/soapbox/entity-store/entities.ts @@ -1,8 +1,9 @@ export enum Entities { ACCOUNTS = 'Accounts', GROUPS = 'Groups', - GROUP_RELATIONSHIPS = 'GroupRelationships', GROUP_MEMBERSHIPS = 'GroupMemberships', + GROUP_RELATIONSHIPS = 'GroupRelationships', + GROUP_TAGS = 'GroupTags', RELATIONSHIPS = 'Relationships', - STATUSES = 'Statuses', + 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 6b4aaff73..24ce3af7d 100644 --- a/app/soapbox/entity-store/hooks/useCreateEntity.ts +++ b/app/soapbox/entity-store/hooks/useCreateEntity.ts @@ -1,3 +1,4 @@ +import { AxiosError } from 'axios'; import { z } from 'zod'; import { useAppDispatch, useLoading } from 'soapbox/hooks'; @@ -23,7 +24,7 @@ function useCreateEntity( const [isSubmitting, setPromise] = useLoading(); const { entityType, listKey } = parseEntitiesPath(expandedPath); - async function createEntity(data: Data, callbacks: EntityCallbacks = {}): Promise { + async function createEntity(data: Data, callbacks: EntityCallbacks = {}): Promise { try { const result = await setPromise(entityFn(data)); const schema = opts.schema || z.custom(); @@ -36,8 +37,12 @@ function useCreateEntity( callbacks.onSuccess(entity); } } catch (error) { - if (callbacks.onError) { - callbacks.onError(error); + if (error instanceof AxiosError) { + if (callbacks.onError) { + callbacks.onError(error); + } + } else { + throw error; } } } diff --git a/app/soapbox/entity-store/hooks/useEntityActions.ts b/app/soapbox/entity-store/hooks/useEntityActions.ts index 8b87c52fd..c7e2e431d 100644 --- a/app/soapbox/entity-store/hooks/useEntityActions.ts +++ b/app/soapbox/entity-store/hooks/useEntityActions.ts @@ -12,8 +12,9 @@ interface UseEntityActionsOpts { } interface EntityActionEndpoints { - post?: string delete?: string + patch?: string + post?: string } function useEntityActions( @@ -30,10 +31,14 @@ function useEntityActions( const { createEntity, isSubmitting: createSubmitting } = useCreateEntity(path, (data) => api.post(endpoints.post!, data), opts); + const { createEntity: updateEntity, isSubmitting: updateSubmitting } = + useCreateEntity(path, (data) => api.patch(endpoints.patch!, data), opts); + return { createEntity, deleteEntity, - isSubmitting: createSubmitting || deleteSubmitting, + updateEntity, + isSubmitting: createSubmitting || deleteSubmitting || updateSubmitting, }; } diff --git a/app/soapbox/features/auth-login/components/login-form.tsx b/app/soapbox/features/auth-login/components/login-form.tsx index a91998671..9a4768903 100644 --- a/app/soapbox/features/auth-login/components/login-form.tsx +++ b/app/soapbox/features/auth-login/components/login-form.tsx @@ -3,13 +3,18 @@ import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { Link } from 'react-router-dom'; import { Button, Form, FormActions, FormGroup, Input, Stack } from 'soapbox/components/ui'; +import { useFeatures } from 'soapbox/hooks'; import ConsumersList from './consumers-list'; const messages = defineMessages({ username: { id: 'login.fields.username_label', - defaultMessage: 'Email or username', + defaultMessage: 'E-mail or username', + }, + email: { + id: 'login.fields.email_label', + defaultMessage: 'E-mail address', }, password: { id: 'login.fields.password_placeholder', @@ -24,6 +29,10 @@ interface ILoginForm { const LoginForm: React.FC = ({ isLoading, handleSubmit }) => { const intl = useIntl(); + const features = useFeatures(); + + const usernameLabel = intl.formatMessage(features.logInWithUsername ? messages.username : messages.email); + const passwordLabel = intl.formatMessage(messages.password); return (
@@ -33,10 +42,10 @@ const LoginForm: React.FC = ({ isLoading, handleSubmit }) => {
- + = ({ isLoading, handleSubmit }) => { = ({ isLoading, handleSubmit }) => { } > { const dispatch = useAppDispatch(); const intl = useIntl(); + const features = useFeatures(); const [isLoading, setIsLoading] = useState(false); const [success, setSuccess] = useState(false); @@ -43,7 +45,7 @@ const PasswordReset = () => {
- + setLoading(false))); + dispatch(replaceHomeTimeline(undefined, { maxId: null }, () => setLoading(false))); if (onPinned) { onPinned(null); diff --git a/app/soapbox/features/group/components/group-tag-list-item.tsx b/app/soapbox/features/group/components/group-tag-list-item.tsx new file mode 100644 index 000000000..47f9f1ef6 --- /dev/null +++ b/app/soapbox/features/group/components/group-tag-list-item.tsx @@ -0,0 +1,175 @@ +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { Link } from 'react-router-dom'; + +import { HStack, IconButton, Stack, Text, Tooltip } from 'soapbox/components/ui'; +import { importEntities } from 'soapbox/entity-store/actions'; +import { Entities } from 'soapbox/entity-store/entities'; +import { useAppDispatch } from 'soapbox/hooks'; +import { useUpdateGroupTag } from 'soapbox/hooks/api'; +import { GroupRoles } from 'soapbox/schemas/group-member'; +import toast from 'soapbox/toast'; +import { shortNumberFormat } from 'soapbox/utils/numbers'; + +import type { Group, GroupTag } from 'soapbox/schemas'; + +const messages = defineMessages({ + hideTag: { id: 'group.tags.hide', defaultMessage: 'Hide topic' }, + showTag: { id: 'group.tags.show', defaultMessage: 'Show topic' }, + total: { id: 'group.tags.total', defaultMessage: 'Total Posts' }, + pinTag: { id: 'group.tags.pin', defaultMessage: 'Pin topic' }, + unpinTag: { id: 'group.tags.unpin', defaultMessage: 'Unpin topic' }, + pinSuccess: { id: 'group.tags.pin.success', defaultMessage: 'Pinned!' }, + unpinSuccess: { id: 'group.tags.unpin.success', defaultMessage: 'Unpinned!' }, + visibleSuccess: { id: 'group.tags.visible.success', defaultMessage: 'Topic marked as visible' }, + hiddenSuccess: { id: 'group.tags.hidden.success', defaultMessage: 'Topic marked as hidden' }, +}); + +interface IGroupMemberListItem { + tag: GroupTag + group: Group + isPinnable: boolean +} + +const GroupTagListItem = (props: IGroupMemberListItem) => { + const { group, tag, isPinnable } = props; + const dispatch = useAppDispatch(); + + const intl = useIntl(); + const { updateGroupTag } = useUpdateGroupTag(group.id, tag.id); + + const isOwner = group.relationship?.role === GroupRoles.OWNER; + const isAdmin = group.relationship?.role === GroupRoles.ADMIN; + const canEdit = isOwner || isAdmin; + + const toggleVisibility = () => { + updateGroupTag({ + group_tag_type: tag.visible ? 'hidden' : 'normal', + }, { + onSuccess() { + const entity = { + ...tag, + visible: !tag.visible, + }; + dispatch(importEntities([entity], Entities.GROUP_TAGS)); + + toast.success( + entity.visible ? + intl.formatMessage(messages.visibleSuccess) : + intl.formatMessage(messages.hiddenSuccess), + ); + }, + }); + }; + + const togglePin = () => { + updateGroupTag({ + group_tag_type: tag.pinned ? 'normal' : 'pinned', + }, { + onSuccess() { + const entity = { + ...tag, + pinned: !tag.pinned, + }; + dispatch(importEntities([entity], Entities.GROUP_TAGS)); + + toast.success( + entity.pinned ? + intl.formatMessage(messages.pinSuccess) : + intl.formatMessage(messages.unpinSuccess), + ); + }, + }); + }; + + const renderPinIcon = () => { + if (isPinnable) { + return ( + + + + ); + } + + if (!isPinnable && tag.pinned) { + return ( + + + + + ); + } + }; + + return ( + + + + + #{tag.name} + + + {intl.formatMessage(messages.total)}: + {' '} + + {shortNumberFormat(tag.uses)} + + + + + + {canEdit ? ( + + {tag.visible ? ( + renderPinIcon() + ) : null} + + + + + + ) : null} + + ); +}; + +export default GroupTagListItem; \ No newline at end of file diff --git a/app/soapbox/features/group/components/group-tags-field.tsx b/app/soapbox/features/group/components/group-tags-field.tsx index e4592a6e4..8502c3a1b 100644 --- a/app/soapbox/features/group/components/group-tags-field.tsx +++ b/app/soapbox/features/group/components/group-tags-field.tsx @@ -3,6 +3,8 @@ import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { Input, Streamfield } from 'soapbox/components/ui'; +import type { StreamfieldComponent } from 'soapbox/components/ui/streamfield/streamfield'; + const messages = defineMessages({ hashtagPlaceholder: { id: 'manage_group.fields.hashtag_placeholder', defaultMessage: 'Add a topic' }, }); @@ -30,12 +32,7 @@ const GroupTagsField: React.FC = ({ tags, onChange, onAddItem, ); }; -interface IHashtagField { - value: string - onChange: (value: string) => void -} - -const HashtagField: React.FC = ({ value, onChange }) => { +const HashtagField: StreamfieldComponent = ({ value, onChange, autoFocus = false }) => { const intl = useIntl(); const handleChange: React.ChangeEventHandler = ({ target }) => { @@ -49,6 +46,7 @@ const HashtagField: React.FC = ({ value, onChange }) => { value={value} onChange={handleChange} placeholder={intl.formatMessage(messages.hashtagPlaceholder)} + autoFocus={autoFocus} /> ); }; diff --git a/app/soapbox/features/group/edit-group.tsx b/app/soapbox/features/group/edit-group.tsx index 296c39e7e..4176f6106 100644 --- a/app/soapbox/features/group/edit-group.tsx +++ b/app/soapbox/features/group/edit-group.tsx @@ -5,6 +5,7 @@ import { Button, Column, Form, FormActions, FormGroup, Icon, Input, Spinner, Tex import { useAppSelector, useInstance } from 'soapbox/hooks'; import { useGroup, useUpdateGroup } from 'soapbox/hooks/api'; import { useImageField, useTextField } from 'soapbox/hooks/forms'; +import toast from 'soapbox/toast'; import { isDefaultAvatar, isDefaultHeader } from 'soapbox/utils/accounts'; import AvatarPicker from './components/group-avatar-picker'; @@ -20,7 +21,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!' }, + groupSaved: { id: 'group.update.success', defaultMessage: 'Group successfully saved' }, }); interface IEditGroup { @@ -61,6 +62,17 @@ const EditGroup: React.FC = ({ params: { groupId } }) => { avatar: avatar.file, header: header.file, tags, + }, { + onSuccess() { + toast.success(intl.formatMessage(messages.groupSaved)); + }, + onError(error) { + const message = (error.response?.data as any)?.error; + + if (error.response?.status === 422 && typeof message !== 'undefined') { + toast.error(message); + } + }, }); setIsSubmitting(false); diff --git a/app/soapbox/features/group/group-tag-timeline.tsx b/app/soapbox/features/group/group-tag-timeline.tsx new file mode 100644 index 000000000..2663c59cf --- /dev/null +++ b/app/soapbox/features/group/group-tag-timeline.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import { Column } from 'soapbox/components/ui'; +import { useGroup, useGroupTag } from 'soapbox/hooks/api'; + +type RouteParams = { id: string, groupId: string }; + +interface IGroupTimeline { + params: RouteParams +} + +const GroupTagTimeline: React.FC = (props) => { + const groupId = props.params.groupId; + const tagId = props.params.id; + + const { group } = useGroup(groupId); + const { tag } = useGroupTag(tagId); + + if (!group) { + return null; + } + + return ( + + {/* TODO */} + + ); +}; + +export default GroupTagTimeline; diff --git a/app/soapbox/features/group/group-tags.tsx b/app/soapbox/features/group/group-tags.tsx new file mode 100644 index 000000000..516fb94df --- /dev/null +++ b/app/soapbox/features/group/group-tags.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import ScrollableList from 'soapbox/components/scrollable-list'; +import { Icon, Stack, Text } from 'soapbox/components/ui'; +import { useGroupTags } from 'soapbox/hooks/api'; +import { useGroup } from 'soapbox/queries/groups'; + +import PlaceholderAccount from '../placeholder/components/placeholder-account'; + +import GroupTagListItem from './components/group-tag-list-item'; + +import type { Group } from 'soapbox/types/entities'; + +interface IGroupTopics { + params: { groupId: string } +} + +const GroupTopics: React.FC = (props) => { + const { groupId } = props.params; + + const { group, isFetching: isFetchingGroup } = useGroup(groupId); + const { tags, isFetching: isFetchingTags, hasNextPage, fetchNextPage } = useGroupTags(groupId); + + const isLoading = isFetchingGroup || isFetchingTags; + + const pinnedTags = tags.filter((tag) => tag.pinned); + const isPinnable = pinnedTags.length < 3; + + return ( + +
+ +
+ + + + + + } + emptyMessageCard={false} + > + {tags.map((tag) => ( + + ))} +
+ ); +}; + +export default GroupTopics; diff --git a/app/soapbox/features/group/manage-group.tsx b/app/soapbox/features/group/manage-group.tsx index dd3efe9cd..5c3ee5c24 100644 --- a/app/soapbox/features/group/manage-group.tsx +++ b/app/soapbox/features/group/manage-group.tsx @@ -5,9 +5,8 @@ import { useHistory } from 'react-router-dom'; import { openModal } from 'soapbox/actions/modals'; import List, { ListItem } from 'soapbox/components/list'; import { CardBody, CardHeader, CardTitle, Column, Spinner, Text } from 'soapbox/components/ui'; -import { useAppDispatch, useGroupsPath } from 'soapbox/hooks'; +import { useAppDispatch, useBackend, useGroupsPath } from 'soapbox/hooks'; import { useDeleteGroup, useGroup } from 'soapbox/hooks/api'; -import { useBackend } from 'soapbox/hooks/useBackend'; import { GroupRoles } from 'soapbox/schemas/group-member'; import toast from 'soapbox/toast'; import { TRUTHSOCIAL } from 'soapbox/utils/features'; diff --git a/app/soapbox/features/groups/components/discover/popular-tags.tsx b/app/soapbox/features/groups/components/discover/popular-tags.tsx new file mode 100644 index 000000000..3b0296191 --- /dev/null +++ b/app/soapbox/features/groups/components/discover/popular-tags.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import Link from 'soapbox/components/link'; +import { HStack, Stack, Text } from 'soapbox/components/ui'; +import { usePopularTags } from 'soapbox/hooks/api'; + +import TagListItem from './tag-list-item'; + +const PopularTags = () => { + const { tags, isFetched, isError } = usePopularTags(); + const isEmpty = (isFetched && tags.length === 0) || isError; + + return ( + + + + + + + + + + + + + + {isEmpty ? ( + + + + ) : ( + + {tags.slice(0, 10).map((tag) => ( + + ))} + + )} + + ); +}; + +export default PopularTags; \ No newline at end of file diff --git a/app/soapbox/features/groups/components/discover/tag-list-item.tsx b/app/soapbox/features/groups/components/discover/tag-list-item.tsx new file mode 100644 index 000000000..c899e1085 --- /dev/null +++ b/app/soapbox/features/groups/components/discover/tag-list-item.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { Link } from 'react-router-dom'; + +import { Stack, Text } from 'soapbox/components/ui'; + +import type { GroupTag } from 'soapbox/schemas'; + +interface ITagListItem { + tag: GroupTag +} + +const TagListItem = (props: ITagListItem) => { + const { tag } = props; + + return ( + + + + #{tag.name} + + + + + :{' '} + {tag.uses} + + + + ); +}; + +export default TagListItem; \ No newline at end of file diff --git a/app/soapbox/features/groups/discover.tsx b/app/soapbox/features/groups/discover.tsx index 4e0c0c70a..6e26c0671 100644 --- a/app/soapbox/features/groups/discover.tsx +++ b/app/soapbox/features/groups/discover.tsx @@ -4,6 +4,7 @@ import { defineMessages, useIntl } from 'react-intl'; import { HStack, Icon, IconButton, Input, Stack } from 'soapbox/components/ui'; import PopularGroups from './components/discover/popular-groups'; +import PopularTags from './components/discover/popular-tags'; import Search from './components/discover/search/search'; import SuggestedGroups from './components/discover/suggested-groups'; import TabBar, { TabItems } from './components/tab-bar'; @@ -71,6 +72,7 @@ const Discover: React.FC = () => { <> + )} diff --git a/app/soapbox/features/groups/tag.tsx b/app/soapbox/features/groups/tag.tsx new file mode 100644 index 000000000..4774a4700 --- /dev/null +++ b/app/soapbox/features/groups/tag.tsx @@ -0,0 +1,117 @@ +import clsx from 'clsx'; +import React, { useCallback, useState } from 'react'; +import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso'; + +import { Column, HStack, Icon } from 'soapbox/components/ui'; +import { useGroupTag, useGroupsFromTag } from 'soapbox/hooks/api'; + +import GroupGridItem from './components/discover/group-grid-item'; +import GroupListItem from './components/discover/group-list-item'; + +import type { Group } from 'soapbox/schemas'; + +enum Layout { + LIST = 'LIST', + GRID = 'GRID' +} + +const GridList: Components['List'] = React.forwardRef((props, ref) => { + const { context, ...rest } = props; + return
; +}); + +interface ITag { + params: { id: string } +} + +const Tag: React.FC = (props) => { + const tagId = props.params.id; + + const [layout, setLayout] = useState(Layout.LIST); + + const { tag, isLoading } = useGroupTag(tagId); + const { groups, hasNextPage, fetchNextPage } = useGroupsFromTag(tagId); + + const handleLoadMore = () => { + if (hasNextPage) { + fetchNextPage(); + } + }; + + const renderGroupList = useCallback((group: Group, index: number) => ( +
+ +
+ ), []); + + const renderGroupGrid = useCallback((group: Group, index: number) => ( +
+ +
+ ), []); + + if (isLoading || !tag) { + return null; + } + + return ( + + + + + + } + > + {layout === Layout.LIST ? ( + renderGroupList(group, index)} + endReached={handleLoadMore} + /> + ) : ( + renderGroupGrid(group, index)} + components={{ + Item: (props) => ( +
+ ), + List: GridList, + }} + endReached={handleLoadMore} + /> + )} + + ); +}; + +export default Tag; diff --git a/app/soapbox/features/groups/tags.tsx b/app/soapbox/features/groups/tags.tsx new file mode 100644 index 000000000..1484665e4 --- /dev/null +++ b/app/soapbox/features/groups/tags.tsx @@ -0,0 +1,62 @@ +import clsx from 'clsx'; +import React from 'react'; +import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; +import { Virtuoso } from 'react-virtuoso'; + +import { Column, Text } from 'soapbox/components/ui'; +import { usePopularTags } from 'soapbox/hooks/api'; + +import TagListItem from './components/discover/tag-list-item'; + +import type { GroupTag } from 'soapbox/schemas'; + +const messages = defineMessages({ + title: { id: 'groups.tags.title', defaultMessage: 'Browse Topics' }, +}); + +const Tags: React.FC = () => { + const intl = useIntl(); + + const { tags, isFetched, isError, hasNextPage, fetchNextPage } = usePopularTags(); + const isEmpty = (isFetched && tags.length === 0) || isError; + + const handleLoadMore = () => { + if (hasNextPage) { + fetchNextPage(); + } + }; + + const renderItem = (index: number, tag: GroupTag) => ( +
+ +
+ ); + + return ( + + {isEmpty ? ( + + + + ) : ( + + )} + + ); +}; + +export default Tags; diff --git a/app/soapbox/features/home-timeline/index.tsx b/app/soapbox/features/home-timeline/index.tsx index aaaec2bb3..611fcf7ce 100644 --- a/app/soapbox/features/home-timeline/index.tsx +++ b/app/soapbox/features/home-timeline/index.tsx @@ -27,9 +27,10 @@ const HomeTimeline: React.FC = () => { const isPartial = useAppSelector(state => state.timelines.get('home')?.isPartial === true); const currentAccountId = useAppSelector(state => state.timelines.get('home')?.feedAccountId as string | undefined); const currentAccountRelationship = useAppSelector(state => currentAccountId ? state.relationships.get(currentAccountId) : null); + const next = useAppSelector(state => state.timelines.get('home')?.next); const handleLoadMore = (maxId: string) => { - dispatch(expandHomeTimeline({ maxId, accountId: currentAccountId })); + dispatch(expandHomeTimeline({ url: next, maxId, accountId: currentAccountId })); }; // Mastodon generates the feed in Redis, and can return a partial timeline @@ -52,7 +53,7 @@ const HomeTimeline: React.FC = () => { }; const handleRefresh = () => { - return dispatch(expandHomeTimeline({ maxId: null, accountId: currentAccountId })); + return dispatch(expandHomeTimeline({ accountId: currentAccountId })); }; useEffect(() => { diff --git a/app/soapbox/features/public-layout/components/header.tsx b/app/soapbox/features/public-layout/components/header.tsx index caa5a3fa3..11853871a 100644 --- a/app/soapbox/features/public-layout/components/header.tsx +++ b/app/soapbox/features/public-layout/components/header.tsx @@ -7,7 +7,7 @@ import { fetchInstance } from 'soapbox/actions/instance'; import { openModal } from 'soapbox/actions/modals'; import SiteLogo from 'soapbox/components/site-logo'; import { Button, Form, HStack, IconButton, Input, Tooltip } from 'soapbox/components/ui'; -import { useSoapboxConfig, useOwnAccount, useAppDispatch, useRegistrationStatus } from 'soapbox/hooks'; +import { useSoapboxConfig, useOwnAccount, useAppDispatch, useRegistrationStatus, useFeatures } from 'soapbox/hooks'; import Sonar from './sonar'; @@ -18,7 +18,8 @@ const messages = defineMessages({ home: { id: 'header.home.label', defaultMessage: 'Home' }, login: { id: 'header.login.label', defaultMessage: 'Log in' }, register: { id: 'header.register.label', defaultMessage: 'Register' }, - username: { id: 'header.login.username.placeholder', defaultMessage: 'Email or username' }, + username: { id: 'header.login.username.placeholder', defaultMessage: 'E-mail or username' }, + email: { id: 'header.login.email.placeholder', defaultMessage: 'E-mail address' }, password: { id: 'header.login.password.label', defaultMessage: 'Password' }, forgotPassword: { id: 'header.login.forgot_password', defaultMessage: 'Forgot password?' }, }); @@ -26,6 +27,7 @@ const messages = defineMessages({ const Header = () => { const dispatch = useAppDispatch(); const intl = useIntl(); + const features = useFeatures(); const account = useOwnAccount(); const soapboxConfig = useSoapboxConfig(); @@ -123,7 +125,7 @@ const Header = () => { value={username} onChange={(event) => setUsername(event.target.value.trim())} type='text' - placeholder={intl.formatMessage(messages.username)} + placeholder={intl.formatMessage(features.logInWithUsername ? messages.username : messages.email)} className='max-w-[200px]' autoCorrect='off' autoCapitalize='off' diff --git a/app/soapbox/features/status/components/detailed-status.tsx b/app/soapbox/features/status/components/detailed-status.tsx index 4fc0f0eaf..05e435d4e 100644 --- a/app/soapbox/features/status/components/detailed-status.tsx +++ b/app/soapbox/features/status/components/detailed-status.tsx @@ -2,20 +2,20 @@ import React, { useEffect, useRef, useState } from 'react'; import { FormattedDate, FormattedMessage, useIntl } from 'react-intl'; import Account from 'soapbox/components/account'; -import Icon from 'soapbox/components/icon'; import StatusContent from 'soapbox/components/status-content'; import StatusMedia from 'soapbox/components/status-media'; import StatusReplyMentions from 'soapbox/components/status-reply-mentions'; import SensitiveContentOverlay from 'soapbox/components/statuses/sensitive-content-overlay'; +import StatusInfo from 'soapbox/components/statuses/status-info'; import TranslateButton from 'soapbox/components/translate-button'; -import { HStack, Stack, Text } from 'soapbox/components/ui'; +import { HStack, Icon, Stack, Text } from 'soapbox/components/ui'; import QuotedStatus from 'soapbox/features/status/containers/quoted-status-container'; import { getActualStatus } from 'soapbox/utils/status'; import StatusInteractionBar from './status-interaction-bar'; import type { List as ImmutableList } from 'immutable'; -import type { Attachment as AttachmentEntity, Status as StatusEntity } from 'soapbox/types/entities'; +import type { Attachment as AttachmentEntity, Group, Status as StatusEntity } from 'soapbox/types/entities'; interface IDetailedStatus { status: StatusEntity @@ -50,6 +50,35 @@ const DetailedStatus: React.FC = ({ onOpenCompareHistoryModal(status); }; + const renderStatusInfo = () => { + if (status.group) { + return ( +
+ } + text={ + + + ) }} + /> + + } + /> +
+ ); + } + }; + const actualStatus = getActualStatus(status); if (!actualStatus) return null; const { account } = actualStatus; @@ -75,14 +104,16 @@ const DetailedStatus: React.FC = ({ } if (actualStatus.visibility === 'direct') { - statusTypeIcon = ; + statusTypeIcon = ; } else if (actualStatus.visibility === 'private') { - statusTypeIcon = ; + statusTypeIcon = ; } return (
+ {renderStatusInfo()} +
= (props) => { const titleMessage = () => { if (status.visibility === 'direct') return messages.titleDirect; - return status.group ? messages.titleGroup : messages.title; + return messages.title; }; return ( diff --git a/app/soapbox/features/test-timeline/index.tsx b/app/soapbox/features/test-timeline/index.tsx index 51ab1491e..136d0d324 100644 --- a/app/soapbox/features/test-timeline/index.tsx +++ b/app/soapbox/features/test-timeline/index.tsx @@ -35,7 +35,7 @@ const TestTimeline: React.FC = () => { React.useEffect(() => { dispatch(importFetchedStatuses(MOCK_STATUSES)); - dispatch(expandTimelineSuccess(timelineId, MOCK_STATUSES, null, false, false, false)); + dispatch(expandTimelineSuccess(timelineId, MOCK_STATUSES, undefined, undefined, false, false, false)); }, []); return ( diff --git a/app/soapbox/features/ui/components/modals/compose-event-modal/compose-event-modal.tsx b/app/soapbox/features/ui/components/modals/compose-event-modal/compose-event-modal.tsx index 2ecbbda4f..6a23268d5 100644 --- a/app/soapbox/features/ui/components/modals/compose-event-modal/compose-event-modal.tsx +++ b/app/soapbox/features/ui/components/modals/compose-event-modal/compose-event-modal.tsx @@ -184,7 +184,7 @@ const ComposeEventModal: React.FC = ({ onClose }) => { {location.description} - {[location.street, location.locality, location.country].filter(val => val.trim()).join(' · ')} + {[location.street, location.locality, location.country].filter(val => val?.trim()).join(' · ')} onChangeLocation(null)} /> diff --git a/app/soapbox/features/ui/components/navbar.tsx b/app/soapbox/features/ui/components/navbar.tsx index 6cfb28728..422d22561 100644 --- a/app/soapbox/features/ui/components/navbar.tsx +++ b/app/soapbox/features/ui/components/navbar.tsx @@ -9,7 +9,7 @@ import { openSidebar } from 'soapbox/actions/sidebar'; import SiteLogo from 'soapbox/components/site-logo'; import { Avatar, Button, Form, HStack, IconButton, Input, Tooltip } from 'soapbox/components/ui'; import Search from 'soapbox/features/compose/components/search'; -import { useAppDispatch, useOwnAccount, useRegistrationStatus } from 'soapbox/hooks'; +import { useAppDispatch, useFeatures, useOwnAccount, useRegistrationStatus } from 'soapbox/hooks'; import ProfileDropdown from './profile-dropdown'; @@ -17,7 +17,8 @@ import type { AxiosError } from 'axios'; const messages = defineMessages({ login: { id: 'navbar.login.action', defaultMessage: 'Log in' }, - username: { id: 'navbar.login.username.placeholder', defaultMessage: 'Email or username' }, + username: { id: 'navbar.login.username.placeholder', defaultMessage: 'E-mail or username' }, + email: { id: 'navbar.login.email.placeholder', defaultMessage: 'E-mail address' }, password: { id: 'navbar.login.password.label', defaultMessage: 'Password' }, forgotPassword: { id: 'navbar.login.forgot_password', defaultMessage: 'Forgot password?' }, }); @@ -25,6 +26,7 @@ const messages = defineMessages({ const Navbar = () => { const dispatch = useAppDispatch(); const intl = useIntl(); + const features = useFeatures(); const { isOpen } = useRegistrationStatus(); const account = useOwnAccount(); const node = useRef(null); @@ -111,7 +113,7 @@ const Navbar = () => { value={username} onChange={(event) => setUsername(event.target.value)} type='text' - placeholder={intl.formatMessage(messages.username)} + placeholder={intl.formatMessage(features.logInWithUsername ? messages.username : messages.email)} className='max-w-[200px]' /> diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index c315591ac..565666465 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -124,8 +124,12 @@ import { GroupsDiscover, GroupsPopular, GroupsSuggested, + GroupsTag, + GroupsTags, PendingGroupRequests, GroupMembers, + GroupTags, + GroupTagTimeline, GroupTimeline, ManageGroup, GroupBlockedMembers, @@ -139,6 +143,8 @@ import { WrappedRoute } from './util/react-router-helpers'; // Without this it ends up in ~8 very commonly used bundles. import 'soapbox/components/status'; +const GroupTagsSlug = withHoc(GroupTags as any, GroupLookupHoc); +const GroupTagTimelineSlug = withHoc(GroupTagTimeline as any, GroupLookupHoc); const GroupTimelineSlug = withHoc(GroupTimeline as any, GroupLookupHoc); const GroupMembersSlug = withHoc(GroupMembers as any, GroupLookupHoc); const GroupGallerySlug = withHoc(GroupGallery as any, GroupLookupHoc); @@ -306,7 +312,11 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {features.groupsDiscovery && } {features.groupsDiscovery && } {features.groupsDiscovery && } + {features.groupsDiscovery && } + {features.groupsDiscovery && } {features.groupsPending && } + {features.groupsTags && } + {features.groupsTags && } {features.groups && } {features.groups && } {features.groups && } @@ -316,6 +326,8 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {features.groups && } {features.groups && } + {features.groupsTags && } + {features.groupsTags && } {features.groups && } {features.groups && } {features.groups && } diff --git a/app/soapbox/features/ui/util/async-components.ts b/app/soapbox/features/ui/util/async-components.ts index f915571d2..e8187db46 100644 --- a/app/soapbox/features/ui/util/async-components.ts +++ b/app/soapbox/features/ui/util/async-components.ts @@ -562,6 +562,14 @@ export function GroupsSuggested() { return import(/* webpackChunkName: "features/groups" */'../../groups/suggested'); } +export function GroupsTag() { + return import(/* webpackChunkName: "features/groups" */'../../groups/tag'); +} + +export function GroupsTags() { + return import(/* webpackChunkName: "features/groups" */'../../groups/tags'); +} + export function PendingGroupRequests() { return import(/* webpackChunkName: "features/groups" */'../../groups/pending-requests'); } @@ -570,6 +578,14 @@ export function GroupMembers() { return import(/* webpackChunkName: "features/groups" */'../../group/group-members'); } +export function GroupTags() { + return import(/* webpackChunkName: "features/groups" */'../../group/group-tags'); +} + +export function GroupTagTimeline() { + return import(/* webpackChunkName: "features/groups" */'../../group/group-tag-timeline'); +} + export function GroupTimeline() { return import(/* webpackChunkName: "features/groups" */'../../group/group-timeline'); } diff --git a/app/soapbox/hooks/api/groups/useGroupTag.ts b/app/soapbox/hooks/api/groups/useGroupTag.ts new file mode 100644 index 000000000..d0e63d74d --- /dev/null +++ b/app/soapbox/hooks/api/groups/useGroupTag.ts @@ -0,0 +1,21 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntity } from 'soapbox/entity-store/hooks'; +import { useApi } from 'soapbox/hooks'; +import { type GroupTag, groupTagSchema } from 'soapbox/schemas'; + +function useGroupTag(tagId: string) { + const api = useApi(); + + const { entity: tag, ...result } = useEntity( + [Entities.GROUP_TAGS, tagId], + () => api.get(`/api/v1/tags/${tagId }`), + { schema: groupTagSchema }, + ); + + return { + ...result, + tag, + }; +} + +export { useGroupTag }; \ No newline at end of file diff --git a/app/soapbox/hooks/api/groups/useGroupTags.ts b/app/soapbox/hooks/api/groups/useGroupTags.ts new file mode 100644 index 000000000..42d81688f --- /dev/null +++ b/app/soapbox/hooks/api/groups/useGroupTags.ts @@ -0,0 +1,23 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntities } from 'soapbox/entity-store/hooks'; +import { useApi } from 'soapbox/hooks/useApi'; +import { groupTagSchema } from 'soapbox/schemas'; + +import type { GroupTag } from 'soapbox/schemas'; + +function useGroupTags(groupId: string) { + const api = useApi(); + + const { entities, ...result } = useEntities( + [Entities.GROUP_TAGS, groupId], + () => api.get(`api/v1/truth/trends/groups/${groupId}/tags`), + { schema: groupTagSchema }, + ); + + return { + ...result, + tags: entities, + }; +} + +export { useGroupTags }; \ No newline at end of file diff --git a/app/soapbox/hooks/api/groups/useGroups.ts b/app/soapbox/hooks/api/groups/useGroups.ts index f77f66cdc..9db3001ce 100644 --- a/app/soapbox/hooks/api/groups/useGroups.ts +++ b/app/soapbox/hooks/api/groups/useGroups.ts @@ -1,8 +1,10 @@ +import { useEffect } from 'react'; import { z } from 'zod'; +import { fetchGroupRelationshipsSuccess } from 'soapbox/actions/groups'; import { Entities } from 'soapbox/entity-store/entities'; import { useEntities, useEntity } from 'soapbox/entity-store/hooks'; -import { useApi } from 'soapbox/hooks'; +import { useApi, useAppDispatch } from 'soapbox/hooks'; import { groupSchema, Group } from 'soapbox/schemas/group'; import { groupRelationshipSchema, GroupRelationship } from 'soapbox/schemas/group-relationship'; @@ -48,12 +50,24 @@ function useGroup(groupId: string, refetch = true) { function useGroupRelationship(groupId: string) { const api = useApi(); + const dispatch = useAppDispatch(); - return useEntity( + const { entity: groupRelationship, ...result } = useEntity( [Entities.GROUP_RELATIONSHIPS, groupId], () => api.get(`/api/v1/groups/relationships?id[]=${groupId}`), { schema: z.array(groupRelationshipSchema).transform(arr => arr[0]) }, ); + + useEffect(() => { + if (groupRelationship?.id) { + dispatch(fetchGroupRelationshipsSuccess([groupRelationship])); + } + }, [groupRelationship?.id]); + + return { + entity: groupRelationship, + ...result, + }; } function useGroupRelationships(groupIds: string[]) { diff --git a/app/soapbox/hooks/api/groups/useGroupsFromTag.ts b/app/soapbox/hooks/api/groups/useGroupsFromTag.ts new file mode 100644 index 000000000..a6b8540dc --- /dev/null +++ b/app/soapbox/hooks/api/groups/useGroupsFromTag.ts @@ -0,0 +1,37 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntities } from 'soapbox/entity-store/hooks'; +import { groupSchema } from 'soapbox/schemas'; + +import { useApi } from '../../useApi'; +import { useFeatures } from '../../useFeatures'; + +import { useGroupRelationships } from './useGroups'; + +import type { Group } from 'soapbox/schemas'; + +function useGroupsFromTag(tagId: string) { + const api = useApi(); + const features = useFeatures(); + + const { entities, ...result } = useEntities( + [Entities.GROUPS, 'tags', tagId], + () => api.get(`/api/v1/tags/${tagId}/groups`), + { + schema: groupSchema, + enabled: features.groupsDiscovery, + }, + ); + const { relationships } = useGroupRelationships(entities.map(entity => entity.id)); + + const groups = entities.map((group) => ({ + ...group, + relationship: relationships[group.id] || null, + })); + + return { + ...result, + groups, + }; +} + +export { useGroupsFromTag }; \ No newline at end of file diff --git a/app/soapbox/hooks/api/groups/usePopularTags.ts b/app/soapbox/hooks/api/groups/usePopularTags.ts new file mode 100644 index 000000000..0bd272a2d --- /dev/null +++ b/app/soapbox/hooks/api/groups/usePopularTags.ts @@ -0,0 +1,27 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntities } from 'soapbox/entity-store/hooks'; +import { GroupTag, groupTagSchema } from 'soapbox/schemas'; + +import { useApi } from '../../useApi'; +import { useFeatures } from '../../useFeatures'; + +function usePopularTags() { + const api = useApi(); + const features = useFeatures(); + + const { entities, ...result } = useEntities( + [Entities.GROUP_TAGS], + () => api.get('/api/v1/groups/tags'), + { + schema: groupTagSchema, + enabled: features.groupsDiscovery, + }, + ); + + return { + ...result, + tags: entities, + }; +} + +export { usePopularTags }; \ No newline at end of file diff --git a/app/soapbox/hooks/api/groups/useUpdateGroupTag.ts b/app/soapbox/hooks/api/groups/useUpdateGroupTag.ts new file mode 100644 index 000000000..1c68c714d --- /dev/null +++ b/app/soapbox/hooks/api/groups/useUpdateGroupTag.ts @@ -0,0 +1,18 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntityActions } from 'soapbox/entity-store/hooks'; + +import type { GroupTag } from 'soapbox/schemas'; + +function useUpdateGroupTag(groupId: string, tagId: string) { + const { updateEntity, ...rest } = useEntityActions( + [Entities.GROUP_TAGS, groupId, tagId], + { patch: `/api/v1/groups/${groupId}/tags/${tagId}` }, + ); + + return { + updateGroupTag: updateEntity, + ...rest, + }; +} + +export { useUpdateGroupTag }; \ No newline at end of file diff --git a/app/soapbox/hooks/api/index.ts b/app/soapbox/hooks/api/index.ts index b5927bf6b..c8e1f67c3 100644 --- a/app/soapbox/hooks/api/index.ts +++ b/app/soapbox/hooks/api/index.ts @@ -11,17 +11,22 @@ export { useCancelMembershipRequest } from './groups/useCancelMembershipRequest' export { useCreateGroup, type CreateGroupParams } from './groups/useCreateGroup'; export { useDeleteGroup } from './groups/useDeleteGroup'; export { useDemoteGroupMember } from './groups/useDemoteGroupMember'; -export { useGroupMedia } from './groups/useGroupMedia'; export { useGroup, useGroups } from './groups/useGroups'; +export { useGroupMedia } from './groups/useGroupMedia'; export { useGroupMembershipRequests } from './groups/useGroupMembershipRequests'; export { useGroupSearch } from './groups/useGroupSearch'; +export { useGroupTag } from './groups/useGroupTag'; +export { useGroupTags } from './groups/useGroupTags'; export { useGroupValidation } from './groups/useGroupValidation'; +export { useGroupsFromTag } from './groups/useGroupsFromTag'; export { useJoinGroup } from './groups/useJoinGroup'; export { useLeaveGroup } from './groups/useLeaveGroup'; +export { usePopularTags } from './groups/usePopularTags'; export { usePromoteGroupMember } from './groups/usePromoteGroupMember'; export { useUpdateGroup } from './groups/useUpdateGroup'; +export { useUpdateGroupTag } from './groups/useUpdateGroupTag'; /** * Relationships */ -export { useRelationships } from './useRelationships'; \ No newline at end of file +export { useRelationships } from './useRelationships'; diff --git a/app/soapbox/hooks/index.ts b/app/soapbox/hooks/index.ts index ef8b6462f..6f52e0c8f 100644 --- a/app/soapbox/hooks/index.ts +++ b/app/soapbox/hooks/index.ts @@ -2,6 +2,7 @@ export { useAccount } from './useAccount'; export { useApi } from './useApi'; export { useAppDispatch } from './useAppDispatch'; export { useAppSelector } from './useAppSelector'; +export { useBackend } from './useBackend'; export { useClickOutside } from './useClickOutside'; export { useCompose } from './useCompose'; export { useDebounce } from './useDebounce'; diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 265cb1bcc..35bfb58a0 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -809,8 +809,20 @@ "group.tabs.all": "All", "group.tabs.media": "Media", "group.tabs.members": "Members", + "group.tabs.tags": "Topics", + "group.tags.empty": "There are no topics in this group yet.", + "group.tags.hidden.success": "Topic marked as hidden", + "group.tags.hide": "Hide topic", "group.tags.hint": "Add up to 3 keywords that will serve as core topics of discussion in the group.", "group.tags.label": "Tags", + "group.tags.pin": "Pin topic", + "group.tags.pin.success": "Pinned!", + "group.tags.show": "Show topic", + "group.tags.total": "Total Posts", + "group.tags.unpin": "Unpin topic", + "group.tags.unpin.success": "Unpinned!", + "group.tags.visible.success": "Topic marked as visible", + "group.update.success": "Group successfully saved", "group.upload_banner": "Upload photo", "groups.discover.popular.empty": "Unable to fetch popular groups at this time. Please check back later.", "groups.discover.popular.show_more": "Show More", @@ -829,6 +841,10 @@ "groups.discover.suggested.empty": "Unable to fetch suggested groups at this time. Please check back later.", "groups.discover.suggested.show_more": "Show More", "groups.discover.suggested.title": "Suggested For You", + "groups.discover.tags.empty": "Unable to fetch popular topics at this time. Please check back later.", + "groups.discover.tags.show_more": "Show More", + "groups.discover.tags.title": "Browse Topics", + "groups.discovery.tags.no_of_groups": "Number of groups", "groups.empty.subtitle": "Start discovering groups to join or create your own.", "groups.empty.title": "No Groups yet", "groups.pending.count": "{number, plural, one {# pending request} other {# pending requests}}", @@ -837,10 +853,12 @@ "groups.pending.label": "Pending Requests", "groups.popular.label": "Suggested Groups", "groups.search.placeholder": "Search My Groups", + "groups.tags.title": "Browse Topics", "hashtag.column_header.tag_mode.all": "and {additional}", "hashtag.column_header.tag_mode.any": "or {additional}", "hashtag.column_header.tag_mode.none": "without {additional}", "header.home.label": "Home", + "header.login.email.placeholder": "E-mail address", "header.login.forgot_password": "Forgot password?", "header.login.label": "Log in", "header.login.password.label": "Password", @@ -925,6 +943,7 @@ "lists.subheading": "Your lists", "loading_indicator.label": "Loading…", "location_search.placeholder": "Find an address", + "login.fields.email_label": "E-mail address", "login.fields.instance_label": "Instance", "login.fields.instance_placeholder": "example.com", "login.fields.otp_code_hint": "Enter the two-factor code generated by your phone app or use one of your recovery codes", @@ -967,7 +986,6 @@ "manage_group.privacy.private.label": "Private (Owner approval required)", "manage_group.privacy.public.hint": "Discoverable. Anyone can join.", "manage_group.privacy.public.label": "Public", - "manage_group.success": "Group saved!", "manage_group.tagline": "Groups connect you with others based on shared interests.", "media_panel.empty_message": "No media found.", "media_panel.title": "Media", @@ -1014,6 +1032,7 @@ "mute_modal.duration": "Duration", "mute_modal.hide_notifications": "Hide notifications from this user?", "navbar.login.action": "Log in", + "navbar.login.email.placeholder": "E-mail address", "navbar.login.forgot_password": "Forgot password?", "navbar.login.password.label": "Password", "navbar.login.username.placeholder": "Email or username", @@ -1116,6 +1135,7 @@ "onboarding.suggestions.title": "Suggested accounts", "onboarding.view_feed": "View Feed", "password_reset.confirmation": "Check your email for confirmation.", + "password_reset.fields.email_placeholder": "E-mail address", "password_reset.fields.username_placeholder": "Email or username", "password_reset.header": "Reset Password", "password_reset.reset": "Reset password", @@ -1465,7 +1485,6 @@ "status.show_original": "Show original", "status.title": "Post Details", "status.title_direct": "Direct message", - "status.title_group": "Group Post Details", "status.translate": "Translate", "status.translated_from_with": "Translated from {lang} using {provider}", "status.unbookmark": "Remove bookmark", diff --git a/app/soapbox/locales/zh-CN.json b/app/soapbox/locales/zh-CN.json index 617c8e727..889752971 100644 --- a/app/soapbox/locales/zh-CN.json +++ b/app/soapbox/locales/zh-CN.json @@ -1464,7 +1464,6 @@ "status.show_original": "显示原文本", "status.title": "帖文详情", "status.title_direct": "私信", - "status.title_group": "群组帖文详情", "status.translate": "翻译", "status.translated_from_with": "使用 {provider} 从 {lang} 翻译而来", "status.unbookmark": "移除书签", diff --git a/app/soapbox/normalizers/__tests__/instance.test.ts b/app/soapbox/normalizers/__tests__/instance.test.ts index 90472e7f8..f2bac4867 100644 --- a/app/soapbox/normalizers/__tests__/instance.test.ts +++ b/app/soapbox/normalizers/__tests__/instance.test.ts @@ -25,7 +25,7 @@ describe('normalizeInstance()', () => { }, groups: { max_characters_name: 50, - max_characters_description: 100, + max_characters_description: 160, }, }, description: '', diff --git a/app/soapbox/normalizers/instance.ts b/app/soapbox/normalizers/instance.ts index 3632b9058..77233c143 100644 --- a/app/soapbox/normalizers/instance.ts +++ b/app/soapbox/normalizers/instance.ts @@ -37,7 +37,7 @@ export const InstanceRecord = ImmutableRecord({ }), groups: ImmutableMap({ max_characters_name: 50, - max_characters_description: 100, + max_characters_description: 160, }), }), description: '', diff --git a/app/soapbox/normalizers/status.ts b/app/soapbox/normalizers/status.ts index 031c99c29..7a71f24a8 100644 --- a/app/soapbox/normalizers/status.ts +++ b/app/soapbox/normalizers/status.ts @@ -20,7 +20,7 @@ import type { ReducerAccount } from 'soapbox/reducers/accounts'; import type { Account, Attachment, Card, Emoji, Group, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities'; export type StatusApprovalStatus = 'pending' | 'approval' | 'rejected'; -export type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct' | 'self'; +export type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct' | 'self' | 'group'; export type EventJoinMode = 'free' | 'restricted' | 'invite'; export type EventJoinState = 'pending' | 'reject' | 'accept'; diff --git a/app/soapbox/pages/group-page.tsx b/app/soapbox/pages/group-page.tsx index 13dda5467..ae383be8c 100644 --- a/app/soapbox/pages/group-page.tsx +++ b/app/soapbox/pages/group-page.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useRouteMatch } from 'react-router-dom'; @@ -13,7 +13,7 @@ import { SignUpPanel, SuggestedGroupsPanel, } from 'soapbox/features/ui/util/async-components'; -import { useOwnAccount } from 'soapbox/hooks'; +import { useFeatures, useOwnAccount } from 'soapbox/hooks'; import { useGroup } from 'soapbox/hooks/api'; import { useGroupMembershipRequests } from 'soapbox/hooks/api/groups/useGroupMembershipRequests'; import { Group } from 'soapbox/schemas'; @@ -24,6 +24,7 @@ const messages = defineMessages({ all: { id: 'group.tabs.all', defaultMessage: 'All' }, members: { id: 'group.tabs.members', defaultMessage: 'Members' }, media: { id: 'group.tabs.media', defaultMessage: 'Media' }, + tags: { id: 'group.tabs.tags', defaultMessage: 'Topics' }, }); interface IGroupPage { @@ -62,6 +63,7 @@ const BlockedBlankslate = ({ group }: { group: Group }) => ( /** Page to display a group. */ const GroupPage: React.FC = ({ params, children }) => { const intl = useIntl(); + const features = useFeatures(); const match = useRouteMatch(); const me = useOwnAccount(); @@ -74,13 +76,29 @@ const GroupPage: React.FC = ({ params, children }) => { const isBlocked = group?.relationship?.blocked_by; const isPrivate = group?.locked; - const items = [ - { + // if ((group as any) === false) { + // return ( + // + // ); + // } + + const tabItems = useMemo(() => { + const items = []; + items.push({ text: intl.formatMessage(messages.all), to: `/group/${group?.slug}`, name: '/group/:groupSlug', - }, - { + }); + + if (features.groupsTags) { + items.push({ + text: intl.formatMessage(messages.tags), + to: `/group/${group?.slug}/tags`, + name: '/group/:groupSlug/tags', + }); + } + + items.push({ text: intl.formatMessage(messages.members), to: `/group/${group?.slug}/members`, name: '/group/:groupSlug/members', @@ -89,9 +107,11 @@ const GroupPage: React.FC = ({ params, children }) => { { text: intl.formatMessage(messages.media), to: `/group/${group?.slug}/media`, - name: '/group/:groupSlug', - }, - ]; + name: '/group/:groupSlug/media', + }); + + return items; + }, [features.groupsTags]); const renderChildren = () => { if (!isMember && isPrivate) { @@ -110,7 +130,7 @@ const GroupPage: React.FC = ({ params, children }) => { diff --git a/app/soapbox/pages/home-page.tsx b/app/soapbox/pages/home-page.tsx index 1a91a0320..2ba7cac0a 100644 --- a/app/soapbox/pages/home-page.tsx +++ b/app/soapbox/pages/home-page.tsx @@ -43,7 +43,7 @@ const HomePage: React.FC = ({ children }) => { <> {me && ( - + diff --git a/app/soapbox/reducers/timelines.ts b/app/soapbox/reducers/timelines.ts index 1713ee964..b5fe0e049 100644 --- a/app/soapbox/reducers/timelines.ts +++ b/app/soapbox/reducers/timelines.ts @@ -46,6 +46,8 @@ const TimelineRecord = ImmutableRecord({ top: true, isLoading: false, hasMore: true, + next: undefined as string | undefined, + prev: undefined as string | undefined, items: ImmutableOrderedSet(), queuedItems: ImmutableOrderedSet(), //max= MAX_QUEUED_ITEMS feedAccountId: null, @@ -87,13 +89,23 @@ const setFailed = (state: State, timelineId: string, failed: boolean) => { return state.update(timelineId, TimelineRecord(), timeline => timeline.set('loadingFailed', failed)); }; -const expandNormalizedTimeline = (state: State, timelineId: string, statuses: ImmutableList>, next: string | null, isPartial: boolean, isLoadingRecent: boolean) => { +const expandNormalizedTimeline = ( + state: State, + timelineId: string, + statuses: ImmutableList>, + next: string | undefined, + prev: string | undefined, + isPartial: boolean, + isLoadingRecent: boolean, +) => { const newIds = getStatusIds(statuses); return state.update(timelineId, TimelineRecord(), timeline => timeline.withMutations(timeline => { timeline.set('isLoading', false); timeline.set('loadingFailed', false); timeline.set('isPartial', isPartial); + timeline.set('next', next); + timeline.set('prev', prev); if (!next && !isLoadingRecent) timeline.set('hasMore', false); @@ -322,7 +334,15 @@ export default function timelines(state: State = initialState, action: AnyAction case TIMELINE_EXPAND_FAIL: return handleExpandFail(state, action.timeline); case TIMELINE_EXPAND_SUCCESS: - return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses) as ImmutableList>, action.next, action.partial, action.isLoadingRecent); + return expandNormalizedTimeline( + state, + action.timeline, + fromJS(action.statuses) as ImmutableList>, + action.next, + action.prev, + action.partial, + action.isLoadingRecent, + ); case TIMELINE_UPDATE: return updateTimeline(state, action.timeline, action.statusId); case TIMELINE_UPDATE_QUEUE: diff --git a/app/soapbox/schemas/group-tag.ts b/app/soapbox/schemas/group-tag.ts index cc64deec6..9fa885569 100644 --- a/app/soapbox/schemas/group-tag.ts +++ b/app/soapbox/schemas/group-tag.ts @@ -1,7 +1,12 @@ -import { z } from 'zod'; +import z from 'zod'; const groupTagSchema = z.object({ + id: z.string(), name: z.string(), + uses: z.number().optional(), + url: z.string().optional(), + pinned: z.boolean().optional().catch(false), + visible: z.boolean().optional().default(true), }); type GroupTag = z.infer; diff --git a/app/soapbox/schemas/index.ts b/app/soapbox/schemas/index.ts index 1eed8905b..a675b52d2 100644 --- a/app/soapbox/schemas/index.ts +++ b/app/soapbox/schemas/index.ts @@ -6,6 +6,7 @@ export { customEmojiSchema } from './custom-emoji'; export { groupSchema } from './group'; export { groupMemberSchema } from './group-member'; export { groupRelationshipSchema } from './group-relationship'; +export { groupTagSchema } from './group-tag'; export { relationshipSchema } from './relationship'; /** @@ -16,4 +17,5 @@ export type { CustomEmoji } from './custom-emoji'; export type { Group } from './group'; export type { GroupMember } from './group-member'; export type { GroupRelationship } from './group-relationship'; +export type { GroupTag } from './group-tag'; export type { Relationship } from './relationship'; diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index afbece3b4..9c5a68bda 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -559,6 +559,11 @@ const getInstanceFeatures = (instance: Instance) => { */ groupsSearch: v.software === TRUTHSOCIAL, + /** + * Can see topics for Groups. + */ + groupsTags: v.software === TRUTHSOCIAL, + /** * Can validate group names. */ @@ -597,6 +602,14 @@ const getInstanceFeatures = (instance: Instance) => { v.software === PLEROMA && gte(v.version, '0.9.9'), ]), + /** + * Can sign in using username instead of e-mail address. + */ + logInWithUsername: any([ + v.software === PLEROMA, + v.software === TRUTHSOCIAL, + ]), + /** * Can perform moderation actions with account and reports. * @see {@link https://docs.joinmastodon.org/methods/admin/} diff --git a/package.json b/package.json index 47af605e4..ddd13f24a 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@babel/preset-typescript": "^7.18.6", "@babel/runtime": "^7.20.13", "@emoji-mart/data": "^1.1.2", - "@floating-ui/react": "^0.21.0", + "@floating-ui/react": "^0.23.0", "@fontsource/inter": "^4.5.1", "@fontsource/roboto-mono": "^4.5.8", "@gamestdio/websocket": "^0.3.2", @@ -61,7 +61,6 @@ "@reach/popover": "^0.18.0", "@reach/rect": "^0.18.0", "@reach/tabs": "^0.18.0", - "@reach/tooltip": "^0.18.0", "@reduxjs/toolkit": "^1.8.1", "@sentry/browser": "^7.37.2", "@sentry/react": "^7.37.2", diff --git a/yarn.lock b/yarn.lock index e2baefa40..5220b11d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1751,10 +1751,10 @@ dependencies: "@floating-ui/dom" "^1.2.1" -"@floating-ui/react@^0.21.0": - version "0.21.1" - resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.21.1.tgz#47cafdff0c79f5aa1067398ee06ea2144d22ea7a" - integrity sha512-ojjsU/rvWEyNDproy1yQW5EDXJnDip8DXpSRh+hogPgZWEp0Y/2UBPxL3yoa53BDYsL+dqJY0osl9r0Jes3eeg== +"@floating-ui/react@^0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.23.0.tgz#8b548235ac4478537757c90a66a3bac9068e29d8" + integrity sha512-Id9zTLSjHtcCjBQm0Stc/fRUBGrnHurL/a1HrtQg8LvL6Ciw9KHma2WT++F17kEfhsPkA0UHYxmp+ijmAy0TCw== dependencies: "@floating-ui/react-dom" "^1.3.0" aria-hidden "^1.1.3" @@ -2749,30 +2749,11 @@ "@reach/polymorphic" "0.18.0" "@reach/utils" "0.18.0" -"@reach/tooltip@^0.18.0": - version "0.18.0" - resolved "https://registry.yarnpkg.com/@reach/tooltip/-/tooltip-0.18.0.tgz#6d416e77a82543af9a57d122962f9c0294fc2a5f" - integrity sha512-yugoTmTjB3qoMk/nUvcnw99MqpyE2TQMOXE29qnQhSqHriRwQhfftjXlTAGTSzsUJmbyms3A/1gQW0X61kjFZw== - dependencies: - "@reach/auto-id" "0.18.0" - "@reach/polymorphic" "0.18.0" - "@reach/portal" "0.18.0" - "@reach/rect" "0.18.0" - "@reach/utils" "0.18.0" - "@reach/visually-hidden" "0.18.0" - "@reach/utils@0.18.0": version "0.18.0" resolved "https://registry.yarnpkg.com/@reach/utils/-/utils-0.18.0.tgz#4f3cebe093dd436eeaff633809bf0f68f4f9d2ee" integrity sha512-KdVMdpTgDyK8FzdKO9SCpiibuy/kbv3pwgfXshTI6tEcQT1OOwj7BAksnzGC0rPz0UholwC+AgkqEl3EJX3M1A== -"@reach/visually-hidden@0.18.0": - version "0.18.0" - resolved "https://registry.yarnpkg.com/@reach/visually-hidden/-/visually-hidden-0.18.0.tgz#17923c08acc5946624c2836b2b09d359b3aa8c27" - integrity sha512-NsJ3oeHJtPc6UOeV6MHMuzQ5sl1ouKhW85i3C0S7VM+klxVlYScBZ2J4UVnWB50A2c+evdVpCnld2YeuyYYwBw== - dependencies: - "@reach/polymorphic" "0.18.0" - "@reduxjs/toolkit@^1.8.1": version "1.8.1" resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.8.1.tgz#94ee1981b8cf9227cda40163a04704a9544c9a9f"