From 3191691fff120d2353eb52bbc43ae08f539ae17c Mon Sep 17 00:00:00 2001 From: billisdead Date: Sun, 17 May 2026 21:52:07 +0200 Subject: [PATCH] feat: add long-press contextual actions on post cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long press any post card to open a context menu with state-aware actions: - Copy text (all states) - ERROR: Retry now, Edit & retry, View error message - QUEUE: Edit, Reschedule (native DateTimePicker → PUT /posts/:id) - PUBLISHED: Repost - DRAFT: Edit & schedule Compose screen now accepts prefillContent/prefillIntegrationIds router params to pre-fill content and channel selection when editing or reposting. Adds expo-clipboard for clipboard support. Co-Authored-By: Claude Sonnet 4.6 --- artifacts/postiz-mobile/app.json | 16 +- .../postiz-mobile/app/(tabs)/compose.tsx | 14 +- artifacts/postiz-mobile/app/(tabs)/posts.tsx | 142 +++++++++++++++++- .../postiz-mobile/components/PostCard.tsx | 10 +- .../postiz-mobile/context/PostizContext.tsx | 1 + artifacts/postiz-mobile/package.json | 1 + pnpm-lock.yaml | 16 ++ 7 files changed, 190 insertions(+), 10 deletions(-) diff --git a/artifacts/postiz-mobile/app.json b/artifacts/postiz-mobile/app.json index 3dca3b2..ff63ac8 100644 --- a/artifacts/postiz-mobile/app.json +++ b/artifacts/postiz-mobile/app.json @@ -23,11 +23,12 @@ "android": { "package": "fr.gyozamancave.postizmobile", "permissions": [ - "READ_EXTERNAL_STORAGE", - "WRITE_EXTERNAL_STORAGE", - "READ_MEDIA_IMAGES", - "RECEIVE_BOOT_COMPLETED", - "VIBRATE" + "android.permission.READ_EXTERNAL_STORAGE", + "android.permission.WRITE_EXTERNAL_STORAGE", + "android.permission.READ_MEDIA_IMAGES", + "android.permission.RECEIVE_BOOT_COMPLETED", + "android.permission.VIBRATE", + "android.permission.RECORD_AUDIO" ] }, "web": { @@ -51,6 +52,11 @@ "experiments": { "typedRoutes": true, "reactCompiler": true + }, + "extra": { + "eas": { + "projectId": "aeaaa2bd-3a27-4771-8e39-f2e14fe0e030" + } } } } diff --git a/artifacts/postiz-mobile/app/(tabs)/compose.tsx b/artifacts/postiz-mobile/app/(tabs)/compose.tsx index 9fe2487..67cc6b5 100644 --- a/artifacts/postiz-mobile/app/(tabs)/compose.tsx +++ b/artifacts/postiz-mobile/app/(tabs)/compose.tsx @@ -5,7 +5,8 @@ import * as Haptics from "expo-haptics"; import { Image } from "expo-image"; import * as ImagePicker from "expo-image-picker"; import { fetch as expoFetch } from "expo/fetch"; -import React, { useState } from "react"; +import { useLocalSearchParams } from "expo-router"; +import React, { useEffect, useState } from "react"; import { ActivityIndicator, Alert, @@ -29,6 +30,10 @@ export default function ComposeScreen() { const insets = useSafeAreaInsets(); const { client, isConfigured, apiKey, baseUrl } = usePostiz(); const queryClient = useQueryClient(); + const { prefillContent, prefillIntegrationIds } = useLocalSearchParams<{ + prefillContent?: string; + prefillIntegrationIds?: string; + }>(); const [content, setContent] = useState(""); const [selectedChannels, setSelectedChannels] = useState([]); const [postNow, setPostNow] = useState(false); @@ -41,6 +46,13 @@ export default function ComposeScreen() { const [uploading, setUploading] = useState(false); const [submitting, setSubmitting] = useState(false); + useEffect(() => { + if (prefillContent) setContent(String(prefillContent)); + if (prefillIntegrationIds) { + setSelectedChannels(String(prefillIntegrationIds).split(",").filter(Boolean)); + } + }, [prefillContent, prefillIntegrationIds]); + const { data: integrations, isLoading: loadingIntegrations } = useQuery({ queryKey: ["integrations", !!client], diff --git a/artifacts/postiz-mobile/app/(tabs)/posts.tsx b/artifacts/postiz-mobile/app/(tabs)/posts.tsx index ff4c6c2..bb53af2 100644 --- a/artifacts/postiz-mobile/app/(tabs)/posts.tsx +++ b/artifacts/postiz-mobile/app/(tabs)/posts.tsx @@ -1,6 +1,10 @@ import { Feather } from "@expo/vector-icons"; +import DateTimePicker from "@react-native-community/datetimepicker"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import axios from "axios"; +import * as Clipboard from "expo-clipboard"; +import * as Haptics from "expo-haptics"; +import { useRouter } from "expo-router"; import React, { useMemo, useState } from "react"; import { ActivityIndicator, @@ -51,9 +55,15 @@ export default function PostsScreen() { const insets = useSafeAreaInsets(); const { client, isConfigured } = usePostiz(); const queryClient = useQueryClient(); + const router = useRouter(); const [filter, setFilter] = useState("all"); const [refreshing, setRefreshing] = useState(false); + // reschedule state + const [reschedulePost, setReschedulePost] = useState(null); + const [rescheduleDate, setRescheduleDate] = useState(new Date()); + const [rescheduleStep, setRescheduleStep] = useState<"date" | "time" | null>(null); + const { startDate, endDate } = useMemo(() => { const s = new Date(); s.setMonth(s.getMonth() - 3); @@ -99,6 +109,101 @@ export default function PostsScreen() { } }; + const handleRetry = async (post: PostizPost) => { + if (!client) return; + const integrations = post.integrations ?? (post.integration ? [post.integration] : []); + try { + const payload = { + type: "now", + date: new Date().toISOString(), + shortLink: false, + tags: [] as string[], + posts: integrations.map((intg) => ({ + integration: { id: intg.id }, + value: [{ content: post.content, id: "", image: post.image ?? [] }], + })), + }; + await client.post("posts", payload); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + queryClient.invalidateQueries({ queryKey: ["posts-list"] }); + Alert.alert("Retried", "Post submitted again."); + } catch (e: unknown) { + const msg = extractError(e); + Alert.alert("Retry failed", msg); + } + }; + + const handlePrefillCompose = (post: PostizPost) => { + const integrations = post.integrations ?? (post.integration ? [post.integration] : []); + router.push({ + pathname: "/(tabs)/compose", + params: { + prefillContent: post.content, + prefillIntegrationIds: integrations.map((i) => i.id).join(","), + }, + }); + }; + + const startReschedule = (post: PostizPost) => { + setReschedulePost(post); + setRescheduleDate(new Date(post.publishDate)); + setRescheduleStep("date"); + }; + + const submitReschedule = async (post: PostizPost, date: Date) => { + if (!client) return; + try { + await client.put(`posts/${post.id}`, { date: date.toISOString() }); + queryClient.invalidateQueries({ queryKey: ["posts-list"] }); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + Alert.alert("Rescheduled", `Post moved to ${date.toLocaleDateString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}`); + } catch (e: unknown) { + const msg = extractError(e); + Alert.alert("Reschedule failed", msg); + } + }; + + const showContextMenu = (post: PostizPost) => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + + const preview = post.content.slice(0, 60) + (post.content.length > 60 ? "…" : ""); + + const buttons: Array<{ text: string; style?: "cancel" | "destructive" | "default"; onPress?: () => void }> = []; + + buttons.push({ + text: "Copy text", + onPress: () => Clipboard.setStringAsync(post.content), + }); + + if (post.state === "ERROR") { + if (post.errorMessage) { + buttons.push({ + text: "View error", + onPress: () => Alert.alert("Error details", post.errorMessage), + }); + } + buttons.push({ text: "Retry now", onPress: () => handleRetry(post) }); + buttons.push({ text: "Edit & retry", onPress: () => handlePrefillCompose(post) }); + } else if (post.state === "QUEUE") { + buttons.push({ text: "Edit", onPress: () => handlePrefillCompose(post) }); + buttons.push({ text: "Reschedule", onPress: () => startReschedule(post) }); + } else if (post.state === "PUBLISHED") { + buttons.push({ text: "Repost", onPress: () => handlePrefillCompose(post) }); + } else if (post.state === "DRAFT") { + buttons.push({ text: "Edit & schedule", onPress: () => handlePrefillCompose(post) }); + } + + buttons.push({ text: "Cancel", style: "cancel" }); + + Alert.alert( + post.state === "ERROR" ? "Failed post" : + post.state === "QUEUE" ? "Scheduled post" : + post.state === "PUBLISHED" ? "Published post" : "Draft", + preview, + buttons + ); + }; + if (!isConfigured) { return ( item.id} renderItem={({ item }) => ( - + )} refreshControl={ 0} /> )} + + {rescheduleStep !== null && reschedulePost !== null && ( + { + if (!date) { + setRescheduleStep(null); + setReschedulePost(null); + return; + } + if (rescheduleStep === "date") { + const merged = new Date(rescheduleDate); + merged.setFullYear(date.getFullYear(), date.getMonth(), date.getDate()); + setRescheduleDate(merged); + setRescheduleStep("time"); + } else { + const merged = new Date(rescheduleDate); + merged.setHours(date.getHours(), date.getMinutes()); + const post = reschedulePost; + setRescheduleStep(null); + setReschedulePost(null); + submitReschedule(post, merged); + } + }} + /> + )} ); } diff --git a/artifacts/postiz-mobile/components/PostCard.tsx b/artifacts/postiz-mobile/components/PostCard.tsx index 8518114..0c98146 100644 --- a/artifacts/postiz-mobile/components/PostCard.tsx +++ b/artifacts/postiz-mobile/components/PostCard.tsx @@ -17,6 +17,7 @@ import { StatusBadge } from "./StatusBadge"; interface PostCardProps { post: PostizPost; onDelete: (id: string) => Promise; + onLongPress: (post: PostizPost) => void; } function formatDate(dateStr: string): string { @@ -43,7 +44,7 @@ function getNetworkIcon(type?: string): React.ComponentProps["na return "globe"; } -export function PostCard({ post, onDelete }: PostCardProps) { +export function PostCard({ post, onDelete, onLongPress }: PostCardProps) { const colors = useColors(); const swipeRef = useRef(null); @@ -100,7 +101,10 @@ export function PostCard({ post, onDelete }: PostCardProps) { rightThreshold={40} friction={2} > - onLongPress(post)} + delayLongPress={400} style={[ styles.card, { backgroundColor: colors.card, borderBottomColor: colors.border }, @@ -140,7 +144,7 @@ export function PostCard({ post, onDelete }: PostCardProps) { {formatDate(post.publishDate)} - + ); } diff --git a/artifacts/postiz-mobile/context/PostizContext.tsx b/artifacts/postiz-mobile/context/PostizContext.tsx index fc42877..202c0ec 100644 --- a/artifacts/postiz-mobile/context/PostizContext.tsx +++ b/artifacts/postiz-mobile/context/PostizContext.tsx @@ -35,6 +35,7 @@ export interface PostizPost { integrations?: PostizIntegration[]; image?: PostizMediaItem[]; group?: string; + errorMessage?: string; } export interface PostizUploadResult { diff --git a/artifacts/postiz-mobile/package.json b/artifacts/postiz-mobile/package.json index d8eb2f9..ad72d74 100644 --- a/artifacts/postiz-mobile/package.json +++ b/artifacts/postiz-mobile/package.json @@ -58,6 +58,7 @@ "dependencies": { "@react-native-community/datetimepicker": "8.4.4", "axios": "^1.15.2", + "expo-clipboard": "~8.0.8", "expo-notifications": "~0.32.17", "expo-secure-store": "~15.0.8", "expo-task-manager": "~14.0.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1dfe0c0..18b0046 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -398,6 +398,9 @@ importers: axios: specifier: ^1.15.2 version: 1.15.2 + expo-clipboard: + specifier: ~8.0.8 + version: 8.0.8(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-notifications: specifier: ~0.32.17 version: 0.32.17(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) @@ -3384,6 +3387,13 @@ packages: react: '*' react-native: '*' + expo-clipboard@8.0.8: + resolution: {integrity: sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-constants@18.0.13: resolution: {integrity: sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==} peerDependencies: @@ -9386,6 +9396,12 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + expo-clipboard@8.0.8(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + expo: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + expo-constants@18.0.13(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)): dependencies: '@expo/config': 12.0.13