From a8432560ba34d8b56932c3c70f8b08d74bce1e6d Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 23 Jul 2025 15:42:07 +0200 Subject: [PATCH] [Glitch] Add button to load new replies in web UI Port 14a781fa24c969a6be4f2ccc3e6e5c9f83db7437 to glitch-soc Signed-off-by: Claire --- .../flavours/glitch/actions/statuses_typed.ts | 9 +- app/javascript/flavours/glitch/api.ts | 46 +++++++- .../flavours/glitch/api/async_refreshes.ts | 5 + .../flavours/glitch/api/statuses.ts | 15 ++- .../glitch/api_types/async_refreshes.ts | 7 ++ .../status/components/refresh_controller.tsx | 111 ++++++++++++++++++ .../flavours/glitch/features/status/index.jsx | 12 +- .../flavours/glitch/reducers/contexts.ts | 12 +- 8 files changed, 204 insertions(+), 13 deletions(-) create mode 100644 app/javascript/flavours/glitch/api/async_refreshes.ts create mode 100644 app/javascript/flavours/glitch/api_types/async_refreshes.ts create mode 100644 app/javascript/flavours/glitch/features/status/components/refresh_controller.tsx diff --git a/app/javascript/flavours/glitch/actions/statuses_typed.ts b/app/javascript/flavours/glitch/actions/statuses_typed.ts index 840dba5bc1..b79d98df07 100644 --- a/app/javascript/flavours/glitch/actions/statuses_typed.ts +++ b/app/javascript/flavours/glitch/actions/statuses_typed.ts @@ -1,3 +1,5 @@ +import { createAction } from '@reduxjs/toolkit'; + import { apiGetContext } from 'flavours/glitch/api/statuses'; import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions'; @@ -6,13 +8,18 @@ import { importFetchedStatuses } from './importer'; export const fetchContext = createDataLoadingThunk( 'status/context', ({ statusId }: { statusId: string }) => apiGetContext(statusId), - (context, { dispatch }) => { + ({ context, refresh }, { dispatch }) => { const statuses = context.ancestors.concat(context.descendants); dispatch(importFetchedStatuses(statuses)); return { context, + refresh, }; }, ); + +export const completeContextRefresh = createAction<{ statusId: string }>( + 'status/context/complete', +); diff --git a/app/javascript/flavours/glitch/api.ts b/app/javascript/flavours/glitch/api.ts index 912948f7d3..ca6dec0974 100644 --- a/app/javascript/flavours/glitch/api.ts +++ b/app/javascript/flavours/glitch/api.ts @@ -15,6 +15,50 @@ export const getLinks = (response: AxiosResponse) => { return LinkHeader.parse(value); }; +export interface AsyncRefreshHeader { + id: string; + retry: number; +} + +const isAsyncRefreshHeader = (obj: object): obj is AsyncRefreshHeader => + 'id' in obj && 'retry' in obj; + +export const getAsyncRefreshHeader = ( + response: AxiosResponse, +): AsyncRefreshHeader | null => { + const value = response.headers['mastodon-async-refresh'] as + | string + | undefined; + + if (!value) { + return null; + } + + const asyncRefreshHeader: Record = {}; + + value.split(/,\s*/).forEach((pair) => { + const [key, val] = pair.split('=', 2); + + let typedValue: string | number; + + if (key && ['id', 'retry'].includes(key) && val) { + if (val.startsWith('"')) { + typedValue = val.slice(1, -1); + } else { + typedValue = parseInt(val); + } + + asyncRefreshHeader[key] = typedValue; + } + }); + + if (isAsyncRefreshHeader(asyncRefreshHeader)) { + return asyncRefreshHeader; + } + + return null; +}; + const csrfHeader: RawAxiosRequestHeaders = {}; const setCSRFHeader = () => { @@ -62,7 +106,7 @@ export default function api(withAuthorization = true) { }); } -type ApiUrl = `v${1 | 2}/${string}`; +type ApiUrl = `v${1 | '1_alpha' | 2}/${string}`; type RequestParamsOrData = Record; export async function apiRequest( diff --git a/app/javascript/flavours/glitch/api/async_refreshes.ts b/app/javascript/flavours/glitch/api/async_refreshes.ts new file mode 100644 index 0000000000..8d0b3dba93 --- /dev/null +++ b/app/javascript/flavours/glitch/api/async_refreshes.ts @@ -0,0 +1,5 @@ +import { apiRequestGet } from 'flavours/glitch/api'; +import type { ApiAsyncRefreshJSON } from 'flavours/glitch/api_types/async_refreshes'; + +export const apiGetAsyncRefresh = (id: string) => + apiRequestGet(`v1_alpha/async_refreshes/${id}`); diff --git a/app/javascript/flavours/glitch/api/statuses.ts b/app/javascript/flavours/glitch/api/statuses.ts index 3b6053a858..7e2a9ee8e0 100644 --- a/app/javascript/flavours/glitch/api/statuses.ts +++ b/app/javascript/flavours/glitch/api/statuses.ts @@ -1,5 +1,14 @@ -import { apiRequestGet } from 'flavours/glitch/api'; +import api, { getAsyncRefreshHeader } from 'flavours/glitch/api'; import type { ApiContextJSON } from 'flavours/glitch/api_types/statuses'; -export const apiGetContext = (statusId: string) => - apiRequestGet(`v1/statuses/${statusId}/context`); +export const apiGetContext = async (statusId: string) => { + const response = await api().request({ + method: 'GET', + url: `/api/v1/statuses/${statusId}/context`, + }); + + return { + context: response.data, + refresh: getAsyncRefreshHeader(response), + }; +}; diff --git a/app/javascript/flavours/glitch/api_types/async_refreshes.ts b/app/javascript/flavours/glitch/api_types/async_refreshes.ts new file mode 100644 index 0000000000..2d2fed2412 --- /dev/null +++ b/app/javascript/flavours/glitch/api_types/async_refreshes.ts @@ -0,0 +1,7 @@ +export interface ApiAsyncRefreshJSON { + async_refresh: { + id: string; + status: 'running' | 'finished'; + result_count: number; + }; +} diff --git a/app/javascript/flavours/glitch/features/status/components/refresh_controller.tsx b/app/javascript/flavours/glitch/features/status/components/refresh_controller.tsx new file mode 100644 index 0000000000..524c5932af --- /dev/null +++ b/app/javascript/flavours/glitch/features/status/components/refresh_controller.tsx @@ -0,0 +1,111 @@ +import { useEffect, useState, useCallback } from 'react'; + +import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import { + fetchContext, + completeContextRefresh, +} from 'flavours/glitch/actions/statuses'; +import type { AsyncRefreshHeader } from 'flavours/glitch/api'; +import { apiGetAsyncRefresh } from 'flavours/glitch/api/async_refreshes'; +import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; +import { useAppSelector, useAppDispatch } from 'flavours/glitch/store'; + +const messages = defineMessages({ + loading: { + id: 'status.context.loading', + defaultMessage: 'Checking for more replies', + }, +}); + +export const RefreshController: React.FC<{ + statusId: string; + withBorder?: boolean; +}> = ({ statusId, withBorder }) => { + const refresh = useAppSelector( + (state) => state.contexts.refreshing[statusId], + ); + const dispatch = useAppDispatch(); + const intl = useIntl(); + const [ready, setReady] = useState(false); + const [loading, setLoading] = useState(false); + + useEffect(() => { + let timeoutId: ReturnType; + + const scheduleRefresh = (refresh: AsyncRefreshHeader) => { + timeoutId = setTimeout(() => { + void apiGetAsyncRefresh(refresh.id).then((result) => { + if (result.async_refresh.status === 'finished') { + dispatch(completeContextRefresh({ statusId })); + + if (result.async_refresh.result_count > 0) { + setReady(true); + } + } else { + scheduleRefresh(refresh); + } + + return ''; + }); + }, refresh.retry * 1000); + }; + + if (refresh) { + scheduleRefresh(refresh); + } + + return () => { + clearTimeout(timeoutId); + }; + }, [dispatch, setReady, statusId, refresh]); + + const handleClick = useCallback(() => { + setLoading(true); + setReady(false); + + dispatch(fetchContext({ statusId })) + .then(() => { + setLoading(false); + return ''; + }) + .catch(() => { + setLoading(false); + }); + }, [dispatch, setReady, statusId]); + + if (ready && !loading) { + return ( + + ); + } + + if (!refresh && !loading) { + return null; + } + + return ( +
+ +
+ ); +}; diff --git a/app/javascript/flavours/glitch/features/status/index.jsx b/app/javascript/flavours/glitch/features/status/index.jsx index da04de0cc7..2a5d378503 100644 --- a/app/javascript/flavours/glitch/features/status/index.jsx +++ b/app/javascript/flavours/glitch/features/status/index.jsx @@ -62,7 +62,7 @@ import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from import ActionBar from './components/action_bar'; import { DetailedStatus } from './components/detailed_status'; - +import { RefreshController } from './components/refresh_controller'; const messages = defineMessages({ revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' }, @@ -572,7 +572,7 @@ class Status extends ImmutablePureComponent { render () { let ancestors, descendants, remoteHint; - const { isLoading, status, settings, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props; + const { isLoading, status, settings, ancestorsIds, descendantsIds, refresh, intl, domain, multiColumn, pictureInPicture } = this.props; const { fullscreen } = this.state; if (isLoading) { @@ -604,11 +604,9 @@ class Status extends ImmutablePureComponent { if (!isLocal) { remoteHint = ( - } - label={{status.getIn(['account', 'acct']).split('@')[1]} }} />} + ); } diff --git a/app/javascript/flavours/glitch/reducers/contexts.ts b/app/javascript/flavours/glitch/reducers/contexts.ts index 9c849a967b..19714963ba 100644 --- a/app/javascript/flavours/glitch/reducers/contexts.ts +++ b/app/javascript/flavours/glitch/reducers/contexts.ts @@ -4,6 +4,7 @@ import type { Draft, UnknownAction } from '@reduxjs/toolkit'; import type { List as ImmutableList } from 'immutable'; import { timelineDelete } from 'flavours/glitch/actions/timelines_typed'; +import type { AsyncRefreshHeader } from 'flavours/glitch/api'; import type { ApiRelationshipJSON } from 'flavours/glitch/api_types/relationships'; import type { ApiStatusJSON, @@ -12,7 +13,7 @@ import type { import type { Status } from 'flavours/glitch/models/status'; import { blockAccountSuccess, muteAccountSuccess } from '../actions/accounts'; -import { fetchContext } from '../actions/statuses'; +import { fetchContext, completeContextRefresh } from '../actions/statuses'; import { TIMELINE_UPDATE } from '../actions/timelines'; import { compareId } from '../compare_id'; @@ -25,11 +26,13 @@ interface TimelineUpdateAction extends UnknownAction { interface State { inReplyTos: Record; replies: Record; + refreshing: Record; } const initialState: State = { inReplyTos: {}, replies: {}, + refreshing: {}, }; const normalizeContext = ( @@ -127,6 +130,13 @@ export const contextsReducer = createReducer(initialState, (builder) => { builder .addCase(fetchContext.fulfilled, (state, action) => { normalizeContext(state, action.meta.arg.statusId, action.payload.context); + + if (action.payload.refresh) { + state.refreshing[action.meta.arg.statusId] = action.payload.refresh; + } + }) + .addCase(completeContextRefresh, (state, action) => { + delete state.refreshing[action.payload.statusId]; }) .addCase(blockAccountSuccess, (state, action) => { filterContexts(