feat: add long-press contextual actions on post cards

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-17 21:52:07 +02:00
parent 803f147fbb
commit 3191691fff
7 changed files with 190 additions and 10 deletions
+11 -5
View File
@@ -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"
}
}
}
}
+13 -1
View File
@@ -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<string[]>([]);
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<PostizIntegration[]>({
queryKey: ["integrations", !!client],
+141 -1
View File
@@ -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<FilterType>("all");
const [refreshing, setRefreshing] = useState(false);
// reschedule state
const [reschedulePost, setReschedulePost] = useState<PostizPost | null>(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 (
<View
@@ -193,7 +298,11 @@ export default function PostsScreen() {
data={filteredPosts}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<PostCard post={item} onDelete={handleDelete} />
<PostCard
post={item}
onDelete={handleDelete}
onLongPress={showContextMenu}
/>
)}
refreshControl={
<RefreshControl
@@ -220,6 +329,37 @@ export default function PostsScreen() {
scrollEnabled={filteredPosts.length > 0}
/>
)}
{rescheduleStep !== null && reschedulePost !== null && (
<DateTimePicker
value={rescheduleDate}
mode={rescheduleStep}
display="default"
minimumDate={rescheduleStep === "date" ? new Date() : undefined}
textColor={colors.foreground}
accentColor={colors.primary}
onChange={(_: unknown, date?: Date) => {
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);
}
}}
/>
)}
</View>
);
}
@@ -17,6 +17,7 @@ import { StatusBadge } from "./StatusBadge";
interface PostCardProps {
post: PostizPost;
onDelete: (id: string) => Promise<void>;
onLongPress: (post: PostizPost) => void;
}
function formatDate(dateStr: string): string {
@@ -43,7 +44,7 @@ function getNetworkIcon(type?: string): React.ComponentProps<typeof Feather>["na
return "globe";
}
export function PostCard({ post, onDelete }: PostCardProps) {
export function PostCard({ post, onDelete, onLongPress }: PostCardProps) {
const colors = useColors();
const swipeRef = useRef<Swipeable>(null);
@@ -100,7 +101,10 @@ export function PostCard({ post, onDelete }: PostCardProps) {
rightThreshold={40}
friction={2}
>
<View
<TouchableOpacity
activeOpacity={0.85}
onLongPress={() => 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)}
</Text>
</View>
</View>
</TouchableOpacity>
</Swipeable>
);
}
@@ -35,6 +35,7 @@ export interface PostizPost {
integrations?: PostizIntegration[];
image?: PostizMediaItem[];
group?: string;
errorMessage?: string;
}
export interface PostizUploadResult {
+1
View File
@@ -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",