pl-fe: allow setting location for posts if supported by backend

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2026-01-25 10:35:01 +01:00
parent 12c6d8755b
commit 98e1c0077e
9 changed files with 177 additions and 18 deletions

View File

@ -18,13 +18,7 @@ import { tagSchema } from './tag';
import { translationSchema } from './translation';
import { datetimeSchema, filteredArray } from './utils';
const statusEventSchema = v.object({
name: v.fallback(v.string(), ''),
start_time: v.fallback(v.nullable(datetimeSchema), null),
end_time: v.fallback(v.nullable(datetimeSchema), null),
join_mode: v.fallback(v.nullable(v.picklist(['free', 'restricted', 'invite', 'external'])), null),
participants_count: v.fallback(v.number(), 0),
location: v.fallback(v.nullable(v.object({
const locationSchema = v.object({
name: v.fallback(v.string(), ''),
url: v.fallback(v.pipe(v.string(), v.url()), ''),
latitude: v.fallback(v.nullable(v.number()), null),
@ -34,7 +28,15 @@ const statusEventSchema = v.object({
locality: v.fallback(v.string(), ''),
region: v.fallback(v.string(), ''),
country: v.fallback(v.string(), ''),
})), null),
});
const statusEventSchema = v.object({
name: v.fallback(v.string(), ''),
start_time: v.fallback(v.nullable(datetimeSchema), null),
end_time: v.fallback(v.nullable(datetimeSchema), null),
join_mode: v.fallback(v.nullable(v.picklist(['free', 'restricted', 'invite', 'external'])), null),
participants_count: v.fallback(v.number(), 0),
location: v.fallback(v.nullable(locationSchema), null),
join_state: v.fallback(v.nullable(v.picklist(['pending', 'reject', 'accept'])), null),
});
@ -112,6 +114,7 @@ const baseStatusSchema = v.object({
interaction_policy: interactionPolicySchema,
content_type: v.fallback(v.nullable(v.string()), null),
location: v.fallback(v.nullable(locationSchema), null),
});
const preprocess = (status: any) => {
@ -166,6 +169,7 @@ const preprocess = (status: any) => {
'event',
'translation',
'rss_feed',
'location',
])),
...(pick(status.friendica || {}, [
'dislikes_count',

View File

@ -1715,6 +1715,8 @@ const getFeatures = (instance: Instance) => {
*/
statusDislikes: v.software === FRIENDICA && gte(v.version, '2023.3.0'),
statusLocation: instance.api_versions['status_location.pleroma.pl-api'] >= 1,
/**
* @see GET /api/web/stories/v1/recent
* @see GET /api/web/stories/v1/viewers

View File

@ -97,6 +97,8 @@ interface CreateStatusOptionalParams {
*/
quote_approval_policy?: 'public' | 'followers' | 'nobody';
location_id?: string;
/**
* If set to true, this status will be "local only" and will NOT be federated beyond the local timeline(s). If set to false (default), this status will be federated to your followers beyond the local timeline(s).
*/

View File

@ -1,6 +1,6 @@
{
"name": "pl-api",
"version": "1.0.0-rc.96",
"version": "1.0.0-rc.97",
"type": "module",
"homepage": "https://codeberg.org/mkljczk/pl-fe/src/branch/develop/packages/pl-api",
"repository": {

View File

@ -20,7 +20,7 @@ import { createStatus } from './statuses';
import type { LinkOptions } from '@tanstack/react-router';
import type { EditorState } from 'lexical';
import type { Account, CreateStatusParams, CustomEmoji, Group, MediaAttachment, Status as BaseStatus, Tag, Poll, ScheduledStatus, InteractionPolicy, UpdateMediaParams } from 'pl-api';
import type { Account, CreateStatusParams, CustomEmoji, Group, MediaAttachment, Status as BaseStatus, Tag, Poll, ScheduledStatus, InteractionPolicy, UpdateMediaParams, Location } from 'pl-api';
import type { AutoSuggestion } from 'pl-fe/components/autosuggest-input';
import type { Emoji } from 'pl-fe/features/emoji';
import type { Status } from 'pl-fe/normalizers/status';
@ -104,6 +104,9 @@ const COMPOSE_HASHTAG_CASING_SUGGESTION_IGNORE = 'COMPOSE_HASHTAG_CASING_SUGGEST
const COMPOSE_REDACTING_OVERWRITE_CHANGE = 'COMPOSE_REDACTING_OVERWRITE_CHANGE' as const;
const COMPOSE_SET_LOCATION = 'COMPOSE_SET_LOCATION' as const;
const COMPOSE_SET_SHOW_LOCATION_PICKER = 'COMPOSE_SET_SHOW_LOCATION_PICKER' as const;
const messages = defineMessages({
scheduleError: { id: 'compose.invalid_schedule', defaultMessage: 'You must schedule a post at least 5 minutes out.' },
success: { id: 'compose.submit_success', defaultMessage: 'Your post was sent!' },
@ -419,6 +422,7 @@ const submitCompose = (composeId: string, opts: SubmitComposeOpts = {}, preview
local_only: compose.localOnly,
interaction_policy: ['public', 'unlisted', 'private'].includes(compose.visibility) && compose.interactionPolicy || undefined,
quote_approval_policy: compose.quoteApprovalPolicy || undefined,
location_id: compose.location?.origin_id || undefined,
preview,
};
@ -989,6 +993,18 @@ const changeComposeRedactingOverwrite = (composeId: string, value: boolean) => (
value,
});
const setComposeLocation = (composeId: string, location: Location | null) => ({
type: COMPOSE_SET_LOCATION,
composeId,
location,
});
const setComposeShowLocationPicker = (composeId: string, showLocation: boolean) => ({
type: COMPOSE_SET_SHOW_LOCATION_PICKER,
composeId,
showLocation,
});
type ComposeAction =
ComposeSetStatusAction
| ReturnType<typeof changeCompose>
@ -1048,7 +1064,9 @@ type ComposeAction =
| ReturnType<typeof ignoreClearLinkSuggestion>
| ReturnType<typeof suggestHashtagCasing>
| ReturnType<typeof ignoreHashtagCasingSuggestion>
| ReturnType<typeof changeComposeRedactingOverwrite>;
| ReturnType<typeof changeComposeRedactingOverwrite>
| ReturnType<typeof setComposeLocation>
| ReturnType<typeof setComposeShowLocationPicker>;
export {
COMPOSE_CHANGE,
@ -1110,6 +1128,8 @@ export {
COMPOSE_HASHTAG_CASING_SUGGESTION_SET,
COMPOSE_HASHTAG_CASING_SUGGESTION_IGNORE,
COMPOSE_REDACTING_OVERWRITE_CHANGE,
COMPOSE_SET_LOCATION,
COMPOSE_SET_SHOW_LOCATION_PICKER,
setComposeToStatus,
replyCompose,
cancelReplyCompose,
@ -1163,6 +1183,8 @@ export {
suggestHashtagCasing,
ignoreHashtagCasingSuggestion,
changeComposeRedactingOverwrite,
setComposeLocation,
setComposeShowLocationPicker,
type ComposeReplyAction,
type ComposeSuggestionSelectAction,
type ComposeAction,

View File

@ -45,6 +45,8 @@ import DriveButton from './drive-button';
import HashtagCasingSuggestion from './hashtag-casing-suggestion';
import InteractionPolicyButton from './interaction-policy-button';
import LanguageDropdown from './language-dropdown';
import LocationButton from './location-button';
import LocationForm from './location-form';
import PollButton from './poll-button';
import PollForm from './polls/poll-form';
import PrivacyDropdown from './privacy-dropdown';
@ -288,16 +290,18 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
{features.scheduledStatuses && <ScheduleButton composeId={id} />}
{anyMedia && features.spoilers && <SensitiveMediaButton composeId={id} />}
{(features.interactionRequests || features.quoteApprovalPolicies) && <InteractionPolicyButton composeId={id} />}
{features.statusLocation && <LocationButton composeId={id} />}
</div>
), [features, id, anyMedia]);
const showModifiers = !condensed && (compose.mediaAttachments.length || compose.isUploading || compose.poll?.options.length || compose.scheduledAt);
const showModifiers = !condensed && (compose.mediaAttachments.length || compose.isUploading || compose.poll?.options.length || compose.scheduledAt || compose.showLocationPicker);
const composeModifiers = showModifiers && (
<div className='⁂-compose-form__modifiers'>
<UploadForm composeId={id} onSubmit={handleSubmit} />
<PollForm composeId={id} />
<ScheduleForm composeId={id} />
<LocationForm composeId={id} />
</div>
);

View File

@ -0,0 +1,50 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { setComposeShowLocationPicker } from 'pl-fe/actions/compose';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useCompose } from 'pl-fe/hooks/use-compose';
import ComposeFormButton from './compose-form-button';
const messages = defineMessages({
show_location_picker: { id: 'location_button.show_location_picker', defaultMessage: 'Show location picker' },
hide_location_picker: { id: 'location_button.hide_location_picker', defaultMessage: 'Hide location picker' },
});
interface ILocationButton {
composeId: string;
}
const LocationButton: React.FC<ILocationButton> = ({ composeId }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const compose = useCompose(composeId);
const unavailable = compose.isUploading;
const active = compose.showLocationPicker;
const onClick = () => {
if (active) {
dispatch(setComposeShowLocationPicker(composeId, false));
} else {
dispatch(setComposeShowLocationPicker(composeId, true));
}
};
if (unavailable) {
return null;
}
return (
<ComposeFormButton
icon={require('@phosphor-icons/core/regular/map-pin.svg')}
title={intl.formatMessage(active ? messages.hide_location_picker : messages.show_location_picker)}
active={active}
onClick={onClick}
/>
);
};
export { LocationButton as default };

View File

@ -0,0 +1,58 @@
import { Location } from 'pl-api';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { setComposeLocation } from 'pl-fe/actions/compose';
import { ADDRESS_ICONS } from 'pl-fe/components/autosuggest-location';
import LocationSearch from 'pl-fe/components/location-search';
import HStack from 'pl-fe/components/ui/hstack';
import Icon from 'pl-fe/components/ui/icon';
import IconButton from 'pl-fe/components/ui/icon-button';
import Stack from 'pl-fe/components/ui/stack';
import Text from 'pl-fe/components/ui/text';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useCompose } from 'pl-fe/hooks/use-compose';
const messages = defineMessages({
resetLocation: { id: 'compose_event.reset_location', defaultMessage: 'Reset location' },
});
interface ILocationForm {
composeId: string;
}
const LocationForm: React.FC<ILocationForm> = ({ composeId }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const { showLocationPicker, location } = useCompose(composeId);
const onChangeLocation = (location: Location | null) => {
dispatch(setComposeLocation(composeId, location));
};
if (!showLocationPicker) {
return null;
}
return (
<div className='⁂-compose-form__schedule'>
{location ? (
<HStack className='h-[38px] text-gray-700 dark:text-gray-500' alignItems='center' space={2}>
<Icon src={ADDRESS_ICONS[location.type] || require('@phosphor-icons/core/regular/map-pin.svg')} />
<Stack className='grow'>
<Text>{location.description}</Text>
<Text theme='muted' size='xs'>{[location.street, location.locality, location.country].filter(val => val?.trim()).join(' · ')}</Text>
</Stack>
<IconButton title={intl.formatMessage(messages.resetLocation)} src={require('@phosphor-icons/core/regular/x.svg')} onClick={() => onChangeLocation(null)} />
</HStack>
) : (
<LocationSearch
onSelected={onChangeLocation}
/>
)}
</div>
);
};
export { LocationForm as default };

View File

@ -60,10 +60,12 @@ import {
COMPOSE_PREVIEW_CANCEL,
COMPOSE_HASHTAG_CASING_SUGGESTION_SET,
COMPOSE_HASHTAG_CASING_SUGGESTION_IGNORE,
type ComposeAction,
type ComposeSuggestionSelectAction,
COMPOSE_REDACTING_OVERWRITE_CHANGE,
COMPOSE_QUOTE_POLICY_OPTION_CHANGE,
COMPOSE_SET_LOCATION,
COMPOSE_SET_SHOW_LOCATION_PICKER,
type ComposeAction,
type ComposeSuggestionSelectAction,
} from '../actions/compose';
import { EVENT_COMPOSE_CANCEL, EVENT_FORM_SET, type EventsAction } from '../actions/events';
import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS, type MeAction } from '../actions/me';
@ -71,7 +73,7 @@ import { FE_NAME } from '../actions/settings';
import { TIMELINE_DELETE, type TimelineAction } from '../actions/timelines';
import { unescapeHTML } from '../utils/html';
import type { Account, CredentialAccount, Instance, InteractionPolicy, MediaAttachment, Status as BaseStatus, Tag, CreateStatusParams } from 'pl-api';
import type { Account, CredentialAccount, Instance, InteractionPolicy, Location, MediaAttachment, Status as BaseStatus, Tag, CreateStatusParams } from 'pl-api';
import type { Emoji } from 'pl-fe/features/emoji';
import type { Language } from 'pl-fe/features/preferences';
import type { Status } from 'pl-fe/normalizers/status';
@ -113,6 +115,7 @@ interface Compose {
// Non-text content
mediaAttachments: Array<MediaAttachment>;
poll: ComposePoll | null;
location: Location | null;
// Post settings
contentType: string;
@ -157,6 +160,7 @@ interface Compose {
preview: Partial<BaseStatus> | null;
suggestedLanguage: string | null;
suggestions: Array<string> | Array<Emoji>;
showLocationPicker: boolean;
// Moderation features
redacting: boolean;
@ -173,6 +177,7 @@ const newCompose = (params: Partial<Compose> = {}): Compose => ({
mediaAttachments: [],
poll: null,
location: null,
contentType: 'text/plain',
interactionPolicy: null,
@ -211,6 +216,7 @@ const newCompose = (params: Partial<Compose> = {}): Compose => ({
preview: null,
suggestedLanguage: null,
suggestions: [],
showLocationPicker: false,
redacting: false,
redactingOverwrite: false,
@ -753,6 +759,17 @@ const compose = (state = initialState, action: ComposeAction | EventsAction | In
return updateCompose(state, action.composeId, compose => {
compose.redactingOverwrite = action.value;
});
case COMPOSE_SET_LOCATION:
return updateCompose(state, action.composeId, compose => {
compose.location = action.location;
});
case COMPOSE_SET_SHOW_LOCATION_PICKER:
return updateCompose(state, action.composeId, compose => {
compose.showLocationPicker = action.showLocation;
if (!action.showLocation) {
compose.location = null;
}
});
default:
return state;
}