From 98e1c0077e9ff2510ce18a6e9020c037b653f084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Sun, 25 Jan 2026 10:35:01 +0100 Subject: [PATCH] pl-fe: allow setting location for posts if supported by backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- packages/pl-api/lib/entities/status.ts | 26 +++++---- packages/pl-api/lib/features.ts | 2 + packages/pl-api/lib/params/statuses.ts | 2 + packages/pl-api/package.json | 2 +- packages/pl-fe/src/actions/compose.ts | 26 ++++++++- .../compose/components/compose-form.tsx | 6 +- .../compose/components/location-button.tsx | 50 ++++++++++++++++ .../compose/components/location-form.tsx | 58 +++++++++++++++++++ packages/pl-fe/src/reducers/compose.ts | 23 +++++++- 9 files changed, 177 insertions(+), 18 deletions(-) create mode 100644 packages/pl-fe/src/features/compose/components/location-button.tsx create mode 100644 packages/pl-fe/src/features/compose/components/location-form.tsx diff --git a/packages/pl-api/lib/entities/status.ts b/packages/pl-api/lib/entities/status.ts index 4c5d38078..22ad9bdbf 100644 --- a/packages/pl-api/lib/entities/status.ts +++ b/packages/pl-api/lib/entities/status.ts @@ -18,23 +18,25 @@ import { tagSchema } from './tag'; import { translationSchema } from './translation'; import { datetimeSchema, filteredArray } from './utils'; +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), + longitude: v.fallback(v.nullable(v.number()), null), + street: v.fallback(v.string(), ''), + postal_code: v.fallback(v.string(), ''), + locality: v.fallback(v.string(), ''), + region: v.fallback(v.string(), ''), + country: v.fallback(v.string(), ''), +}); + 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({ - name: v.fallback(v.string(), ''), - url: v.fallback(v.pipe(v.string(), v.url()), ''), - latitude: v.fallback(v.nullable(v.number()), null), - longitude: v.fallback(v.nullable(v.number()), null), - street: v.fallback(v.string(), ''), - postal_code: v.fallback(v.string(), ''), - locality: v.fallback(v.string(), ''), - region: v.fallback(v.string(), ''), - country: v.fallback(v.string(), ''), - })), null), + 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', diff --git a/packages/pl-api/lib/features.ts b/packages/pl-api/lib/features.ts index b336cc23d..97f256af0 100644 --- a/packages/pl-api/lib/features.ts +++ b/packages/pl-api/lib/features.ts @@ -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 diff --git a/packages/pl-api/lib/params/statuses.ts b/packages/pl-api/lib/params/statuses.ts index b2688cba9..5b1f27b6b 100644 --- a/packages/pl-api/lib/params/statuses.ts +++ b/packages/pl-api/lib/params/statuses.ts @@ -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). */ diff --git a/packages/pl-api/package.json b/packages/pl-api/package.json index 76cb69d65..5ed4995bf 100644 --- a/packages/pl-api/package.json +++ b/packages/pl-api/package.json @@ -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": { diff --git a/packages/pl-fe/src/actions/compose.ts b/packages/pl-fe/src/actions/compose.ts index e29e4a1b6..1338b5edc 100644 --- a/packages/pl-fe/src/actions/compose.ts +++ b/packages/pl-fe/src/actions/compose.ts @@ -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 @@ -1048,7 +1064,9 @@ type ComposeAction = | ReturnType | ReturnType | ReturnType - | ReturnType; + | ReturnType + | ReturnType + | ReturnType; 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, diff --git a/packages/pl-fe/src/features/compose/components/compose-form.tsx b/packages/pl-fe/src/features/compose/components/compose-form.tsx index 331e992fe..525feaace 100644 --- a/packages/pl-fe/src/features/compose/components/compose-form.tsx +++ b/packages/pl-fe/src/features/compose/components/compose-form.tsx @@ -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, shouldCondense, autoFocus, clickab {features.scheduledStatuses && } {anyMedia && features.spoilers && } {(features.interactionRequests || features.quoteApprovalPolicies) && } + {features.statusLocation && } ), [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 && (
+
); diff --git a/packages/pl-fe/src/features/compose/components/location-button.tsx b/packages/pl-fe/src/features/compose/components/location-button.tsx new file mode 100644 index 000000000..2fc573fc1 --- /dev/null +++ b/packages/pl-fe/src/features/compose/components/location-button.tsx @@ -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 = ({ 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 ( + + ); +}; + +export { LocationButton as default }; diff --git a/packages/pl-fe/src/features/compose/components/location-form.tsx b/packages/pl-fe/src/features/compose/components/location-form.tsx new file mode 100644 index 000000000..97ee0f79d --- /dev/null +++ b/packages/pl-fe/src/features/compose/components/location-form.tsx @@ -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 = ({ 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 ( +
+ {location ? ( + + + + {location.description} + {[location.street, location.locality, location.country].filter(val => val?.trim()).join(' · ')} + + onChangeLocation(null)} /> + + ) : ( + + )} +
+ ); +}; + +export { LocationForm as default }; diff --git a/packages/pl-fe/src/reducers/compose.ts b/packages/pl-fe/src/reducers/compose.ts index b239f5e8c..230d4954c 100644 --- a/packages/pl-fe/src/reducers/compose.ts +++ b/packages/pl-fe/src/reducers/compose.ts @@ -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; poll: ComposePoll | null; + location: Location | null; // Post settings contentType: string; @@ -157,6 +160,7 @@ interface Compose { preview: Partial | null; suggestedLanguage: string | null; suggestions: Array | Array; + showLocationPicker: boolean; // Moderation features redacting: boolean; @@ -173,6 +177,7 @@ const newCompose = (params: Partial = {}): Compose => ({ mediaAttachments: [], poll: null, + location: null, contentType: 'text/plain', interactionPolicy: null, @@ -211,6 +216,7 @@ const newCompose = (params: Partial = {}): 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; }