8b7a2eb644
Release APK / build (push) Has been cancelled
- PostizContext: new PostizWorkspace type, multi-workspace storage (postiz_workspaces_v2), auto-migration from legacy single config, addWorkspace / updateWorkspace / removeWorkspace, clients map - Settings: full rewrite with workspace card list (add / edit / delete) - Compose: channels displayed in two levels — workspace section then network type (X/Twitter, Instagram, LinkedIn...) within each workspace; submit routes posts and image uploads per workspace - MediaLibraryModal: workspace tabs when multiple workspaces configured, returned items carry their workspaceId Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
802 lines
31 KiB
TypeScript
802 lines
31 KiB
TypeScript
import { Feather } from "@expo/vector-icons";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import DateTimePicker from "@react-native-community/datetimepicker";
|
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
import * as Haptics from "expo-haptics";
|
|
import { Image } from "expo-image";
|
|
import * as ImageManipulator from "expo-image-manipulator";
|
|
import * as ImagePicker from "expo-image-picker";
|
|
import { fetch as expoFetch } from "expo/fetch";
|
|
import { useLocalSearchParams } from "expo-router";
|
|
import React, { useEffect, useMemo, useState } from "react";
|
|
import {
|
|
ActivityIndicator,
|
|
Alert,
|
|
Platform,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Switch,
|
|
Text,
|
|
TextInput,
|
|
TouchableOpacity,
|
|
View,
|
|
} from "react-native";
|
|
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import { ChannelChip } from "@/components/ChannelChip";
|
|
import { LibraryMediaItem, MediaLibraryModal } from "@/components/MediaLibraryModal";
|
|
import {
|
|
PostizIntegration,
|
|
PostizUploadResult,
|
|
PostizWorkspace,
|
|
usePostiz,
|
|
} from "@/context/PostizContext";
|
|
import { useColors } from "@/hooks/useColors";
|
|
|
|
const DRAFT_STORAGE_KEY = "postiz_local_draft";
|
|
const MAX_IMAGES = 4;
|
|
|
|
const NETWORK_CHAR_LIMITS: Record<string, number> = {
|
|
twitter: 280, x: 280,
|
|
instagram: 2200,
|
|
linkedin: 3000,
|
|
facebook: 63206,
|
|
youtube: 5000,
|
|
tiktok: 2200,
|
|
};
|
|
|
|
// Integration enriched with its workspace info
|
|
type IntegrationWithWorkspace = PostizIntegration & {
|
|
workspaceId: string;
|
|
workspaceName: string;
|
|
workspace: PostizWorkspace;
|
|
};
|
|
|
|
type MediaItem =
|
|
| { type: "local"; uri: string }
|
|
| { type: "uploaded"; id: string; path: string; workspaceId: string };
|
|
|
|
// Maps a type string to a display label, used for grouping within a workspace
|
|
function networkLabel(intg: PostizIntegration): string {
|
|
const t = (intg.type ?? intg.internalType ?? "").toLowerCase();
|
|
if (t.includes("twitter") || t.includes("x-") || t === "x") return "X / Twitter";
|
|
if (t.includes("instagram")) return "Instagram";
|
|
if (t.includes("linkedin")) return "LinkedIn";
|
|
if (t.includes("facebook")) return "Facebook";
|
|
if (t.includes("tiktok")) return "TikTok";
|
|
if (t.includes("youtube")) return "YouTube";
|
|
if (t.includes("pinterest")) return "Pinterest";
|
|
if (t.includes("mastodon")) return "Mastodon";
|
|
if (t.includes("bluesky") || t.includes("bsky")) return "Bluesky";
|
|
if (t.includes("threads")) return "Threads";
|
|
if (t.includes("reddit")) return "Reddit";
|
|
return "Other";
|
|
}
|
|
|
|
function resolveMediaUrl(path: string, baseUrl: string): string {
|
|
if (path.startsWith("http://") || path.startsWith("https://")) return path;
|
|
const origin = baseUrl.replace(/\/api\/.*$/, "");
|
|
return `${origin}/${path.replace(/^\//, "")}`;
|
|
}
|
|
|
|
export default function ComposeScreen() {
|
|
const colors = useColors();
|
|
const insets = useSafeAreaInsets();
|
|
const { workspaces, clients, isConfigured } = usePostiz();
|
|
const queryClient = useQueryClient();
|
|
const { prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId } =
|
|
useLocalSearchParams<{
|
|
prefillContent?: string;
|
|
prefillIntegrationIds?: string;
|
|
prefillImagePath?: string;
|
|
prefillImageId?: string;
|
|
}>();
|
|
|
|
const [content, setContent] = useState("");
|
|
const [selectedChannels, setSelectedChannels] = useState<string[]>([]);
|
|
const [postNow, setPostNow] = useState(false);
|
|
const [scheduleDate, setScheduleDate] = useState(
|
|
() => new Date(Date.now() + 60 * 60 * 1000)
|
|
);
|
|
const [showDatePicker, setShowDatePicker] = useState(false);
|
|
const [showTimePicker, setShowTimePicker] = useState(false);
|
|
const [mediaItems, setMediaItems] = useState<MediaItem[]>([]);
|
|
const [showMediaLibrary, setShowMediaLibrary] = useState(false);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [draftBanner, setDraftBanner] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (prefillContent) setContent(String(prefillContent));
|
|
if (prefillIntegrationIds) {
|
|
setSelectedChannels(String(prefillIntegrationIds).split(",").filter(Boolean));
|
|
}
|
|
if (prefillImagePath && prefillImageId) {
|
|
// Prefilled image has unknown workspace; associate with first workspace
|
|
const wsId = workspaces[0]?.id ?? "";
|
|
setMediaItems([{ type: "uploaded", id: String(prefillImageId), path: String(prefillImagePath), workspaceId: wsId }]);
|
|
}
|
|
}, [prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId, workspaces]);
|
|
|
|
useEffect(() => {
|
|
if (prefillContent) return;
|
|
AsyncStorage.getItem(DRAFT_STORAGE_KEY).then((raw) => {
|
|
if (!raw) return;
|
|
try {
|
|
const draft = JSON.parse(raw);
|
|
if (draft?.content) setDraftBanner(true);
|
|
} catch {}
|
|
});
|
|
}, [prefillContent]);
|
|
|
|
// Fetch integrations from ALL workspaces in parallel
|
|
const { data: allIntegrations, isLoading: loadingIntegrations } =
|
|
useQuery<IntegrationWithWorkspace[]>({
|
|
queryKey: ["integrations-all", workspaces.map((w) => w.id).join(",")],
|
|
queryFn: async () => {
|
|
const results = await Promise.all(
|
|
workspaces.map(async (ws) => {
|
|
const client = clients[ws.id];
|
|
if (!client) return [];
|
|
const res = await client.get("integrations");
|
|
const list: PostizIntegration[] = Array.isArray(res.data)
|
|
? res.data
|
|
: (res.data?.integrations ?? []);
|
|
return list.map((i): IntegrationWithWorkspace => ({
|
|
...i,
|
|
workspaceId: ws.id,
|
|
workspaceName: ws.name,
|
|
workspace: ws,
|
|
}));
|
|
})
|
|
);
|
|
return results.flat();
|
|
},
|
|
enabled: workspaces.length > 0 && Object.keys(clients).length > 0,
|
|
staleTime: 60000,
|
|
});
|
|
|
|
// Group: workspace → network label → integrations
|
|
const grouped = useMemo(() => {
|
|
if (!allIntegrations) return [];
|
|
const byWorkspace = new Map<string, IntegrationWithWorkspace[]>();
|
|
for (const intg of allIntegrations) {
|
|
if (!byWorkspace.has(intg.workspaceId)) byWorkspace.set(intg.workspaceId, []);
|
|
byWorkspace.get(intg.workspaceId)!.push(intg);
|
|
}
|
|
return workspaces
|
|
.filter((ws) => byWorkspace.has(ws.id))
|
|
.map((ws) => {
|
|
const intgs = byWorkspace.get(ws.id)!;
|
|
const byNetwork = new Map<string, IntegrationWithWorkspace[]>();
|
|
for (const intg of intgs) {
|
|
const key = networkLabel(intg);
|
|
if (!byNetwork.has(key)) byNetwork.set(key, []);
|
|
byNetwork.get(key)!.push(intg);
|
|
}
|
|
return {
|
|
workspace: ws,
|
|
networks: Array.from(byNetwork.entries()).map(([label, channels]) => ({ label, channels })),
|
|
};
|
|
});
|
|
}, [allIntegrations, workspaces]);
|
|
|
|
const effectiveCharLimit = useMemo(() => {
|
|
if (selectedChannels.length === 0 || !allIntegrations) return 3000;
|
|
const selected = allIntegrations.filter((i) => selectedChannels.includes(i.id));
|
|
const limits = selected.map((i) => {
|
|
const t = (i.type ?? i.internalType ?? "").toLowerCase();
|
|
for (const [key, limit] of Object.entries(NETWORK_CHAR_LIMITS)) {
|
|
if (t.includes(key)) return limit;
|
|
}
|
|
return 3000;
|
|
});
|
|
return Math.min(...limits);
|
|
}, [selectedChannels, allIntegrations]);
|
|
|
|
const saveDraft = async () => {
|
|
const draft = { content, integrationIds: selectedChannels };
|
|
await AsyncStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft));
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
Alert.alert("Draft saved", "Your draft has been saved locally.");
|
|
};
|
|
|
|
const restoreDraft = async () => {
|
|
const raw = await AsyncStorage.getItem(DRAFT_STORAGE_KEY);
|
|
if (!raw) return;
|
|
try {
|
|
const draft = JSON.parse(raw);
|
|
if (draft.content) setContent(draft.content);
|
|
if (draft.integrationIds?.length) setSelectedChannels(draft.integrationIds);
|
|
setDraftBanner(false);
|
|
} catch {}
|
|
};
|
|
|
|
const toggleChannel = (id: string) => {
|
|
setSelectedChannels((prev) =>
|
|
prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]
|
|
);
|
|
};
|
|
|
|
const pickImage = async () => {
|
|
if (mediaItems.length >= MAX_IMAGES) {
|
|
Alert.alert("Max images", `You can add up to ${MAX_IMAGES} images per post.`);
|
|
return;
|
|
}
|
|
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
if (status !== "granted") {
|
|
Alert.alert("Permission required", "Allow access to your photo library.");
|
|
return;
|
|
}
|
|
const remaining = MAX_IMAGES - mediaItems.length;
|
|
const result = await ImagePicker.launchImageLibraryAsync({
|
|
mediaTypes: ["images"],
|
|
allowsMultipleSelection: true,
|
|
selectionLimit: remaining,
|
|
allowsEditing: false,
|
|
quality: 1,
|
|
});
|
|
if (!result.canceled && result.assets.length > 0) {
|
|
const MAX_DIM = 1920;
|
|
const processed: string[] = [];
|
|
for (const asset of result.assets) {
|
|
const w = asset.width ?? 0;
|
|
const h = asset.height ?? 0;
|
|
if (w > MAX_DIM || h > MAX_DIM) {
|
|
const landscape = w >= h;
|
|
const resized = await ImageManipulator.manipulateAsync(
|
|
asset.uri,
|
|
[{ resize: landscape ? { width: MAX_DIM } : { height: MAX_DIM } }],
|
|
{ compress: 0.85, format: ImageManipulator.SaveFormat.JPEG }
|
|
);
|
|
processed.push(resized.uri);
|
|
} else {
|
|
processed.push(asset.uri);
|
|
}
|
|
}
|
|
setMediaItems((prev) =>
|
|
[...prev, ...processed.map((uri): MediaItem => ({ type: "local", uri }))].slice(0, MAX_IMAGES)
|
|
);
|
|
}
|
|
};
|
|
|
|
const removeMediaItem = (index: number) => {
|
|
setMediaItems((prev) => prev.filter((_, i) => i !== index));
|
|
};
|
|
|
|
// Upload local images to a specific workspace, returns { id, path }[]
|
|
const uploadLocalToWorkspace = async (
|
|
localUris: string[],
|
|
ws: PostizWorkspace
|
|
): Promise<Array<{ id: string; path: string }>> => {
|
|
const result: Array<{ id: string; path: string }> = [];
|
|
for (const uri of localUris) {
|
|
const formData = new FormData();
|
|
if (Platform.OS === "web") {
|
|
const response = await expoFetch(uri);
|
|
const blob = await response.blob();
|
|
formData.append("file", blob, "upload.jpg");
|
|
} else {
|
|
formData.append("file", {
|
|
uri,
|
|
name: "upload.jpg",
|
|
type: "image/jpeg",
|
|
} as unknown as Blob);
|
|
}
|
|
// eslint-disable-next-line no-undef
|
|
const uploadRes = await globalThis.fetch(`${ws.baseUrl}/upload`, {
|
|
method: "POST",
|
|
headers: { Authorization: ws.apiKey },
|
|
body: formData,
|
|
});
|
|
if (!uploadRes.ok) {
|
|
const raw = await uploadRes.text().catch(() => uploadRes.statusText);
|
|
throw new Error(`[${ws.name}] Upload HTTP ${uploadRes.status}: ${raw.slice(0, 200)}`);
|
|
}
|
|
const uploaded = (await uploadRes.json()) as PostizUploadResult;
|
|
result.push({ id: uploaded.id, path: uploaded.path });
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if (!isConfigured) return;
|
|
if (!content.trim()) {
|
|
Alert.alert("Empty post", "Please write something before posting.");
|
|
return;
|
|
}
|
|
if (selectedChannels.length === 0) {
|
|
Alert.alert("No channel", "Please select at least one channel.");
|
|
return;
|
|
}
|
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
|
setSubmitting(true);
|
|
|
|
try {
|
|
// Group selected channels by workspace
|
|
const byWorkspace = new Map<string, { ws: PostizWorkspace; channelIds: string[] }>();
|
|
for (const channelId of selectedChannels) {
|
|
const intg = allIntegrations?.find((i) => i.id === channelId);
|
|
if (!intg) continue;
|
|
if (!byWorkspace.has(intg.workspaceId)) {
|
|
byWorkspace.set(intg.workspaceId, { ws: intg.workspace, channelIds: [] });
|
|
}
|
|
byWorkspace.get(intg.workspaceId)!.channelIds.push(channelId);
|
|
}
|
|
|
|
const localUris = mediaItems.filter((m): m is MediaItem & { type: "local" } => m.type === "local").map((m) => m.uri);
|
|
const hasLocalImages = localUris.length > 0;
|
|
if (hasLocalImages) setUploading(true);
|
|
|
|
await Promise.all(
|
|
Array.from(byWorkspace.values()).map(async ({ ws, channelIds }) => {
|
|
// Already-uploaded media belonging to this workspace
|
|
const uploadedForWs = mediaItems
|
|
.filter((m): m is MediaItem & { type: "uploaded" } => m.type === "uploaded" && m.workspaceId === ws.id)
|
|
.map(({ id, path }) => ({ id, path }));
|
|
|
|
// Upload local images to this workspace
|
|
const localUploaded = hasLocalImages
|
|
? await uploadLocalToWorkspace(localUris, ws)
|
|
: [];
|
|
|
|
const media = [...uploadedForWs, ...localUploaded];
|
|
|
|
const payload = {
|
|
type: postNow ? "now" : "schedule",
|
|
date: postNow ? new Date().toISOString() : scheduleDate.toISOString(),
|
|
shortLink: false,
|
|
tags: [] as string[],
|
|
posts: channelIds.map((integrationId) => ({
|
|
integration: { id: integrationId },
|
|
value: [{ content: content.trim(), image: media }],
|
|
})),
|
|
};
|
|
|
|
const body = JSON.stringify(payload);
|
|
console.log("[compose] POST", `${ws.baseUrl}/posts`, body);
|
|
|
|
// eslint-disable-next-line no-undef
|
|
const res = await globalThis.fetch(`${ws.baseUrl}/posts`, {
|
|
method: "POST",
|
|
headers: { Authorization: ws.apiKey, "Content-Type": "application/json" },
|
|
body,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
let detail = "";
|
|
try {
|
|
const raw = await res.text();
|
|
console.log(`[compose][${ws.name}] error body:`, raw);
|
|
detail = raw.slice(0, 500);
|
|
} catch { detail = res.statusText; }
|
|
throw new Error(`[${ws.name}] HTTP ${res.status}: ${detail}`);
|
|
}
|
|
})
|
|
);
|
|
|
|
if (hasLocalImages) setUploading(false);
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
await AsyncStorage.removeItem(DRAFT_STORAGE_KEY);
|
|
Alert.alert(
|
|
"Posted!",
|
|
postNow ? "Your post has been published." : "Post scheduled successfully.",
|
|
[{ text: "OK", onPress: resetForm }]
|
|
);
|
|
queryClient.invalidateQueries({ queryKey: ["posts"] });
|
|
queryClient.invalidateQueries({ queryKey: ["posts-list"] });
|
|
} catch (e: unknown) {
|
|
setUploading(false);
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
const msg = e instanceof Error ? e.message : "Could not submit post.";
|
|
Alert.alert("Failed", msg);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const resetForm = () => {
|
|
setContent("");
|
|
setSelectedChannels([]);
|
|
setPostNow(false);
|
|
setMediaItems([]);
|
|
setDraftBanner(false);
|
|
setScheduleDate(new Date(Date.now() + 60 * 60 * 1000));
|
|
};
|
|
|
|
const formatDateLabel = (d: Date) =>
|
|
d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
|
|
|
const formatTimeLabel = (d: Date) =>
|
|
d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
|
|
|
|
if (!isConfigured) {
|
|
return (
|
|
<View style={[styles.centered, { backgroundColor: colors.background }]}>
|
|
<Feather name="lock" size={32} color={colors.mutedForeground} />
|
|
<Text style={[styles.sectionTitle, { color: colors.foreground }]}>Not Configured</Text>
|
|
<Text style={[styles.hint, { color: colors.mutedForeground }]}>Add a workspace in Settings</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<KeyboardAwareScrollView
|
|
style={{ flex: 1, backgroundColor: colors.background }}
|
|
contentContainerStyle={[
|
|
styles.container,
|
|
{
|
|
paddingTop: Platform.OS === "web" ? 67 : 16,
|
|
paddingBottom: Platform.OS === "web" ? 100 : insets.bottom + 80,
|
|
},
|
|
]}
|
|
bottomOffset={80}
|
|
keyboardShouldPersistTaps="handled"
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{draftBanner && (
|
|
<View style={[styles.draftBanner, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
|
<Feather name="file-text" size={14} color={colors.primary} />
|
|
<Text style={[styles.draftBannerText, { color: colors.foreground }]}>You have a saved draft</Text>
|
|
<TouchableOpacity onPress={restoreDraft} activeOpacity={0.7}>
|
|
<Text style={[styles.draftBannerAction, { color: colors.primary }]}>Restore</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity onPress={() => setDraftBanner(false)} activeOpacity={0.7}>
|
|
<Feather name="x" size={14} color={colors.mutedForeground} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
)}
|
|
|
|
<View style={[styles.textArea, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
|
<TextInput
|
|
style={[styles.textInput, { color: colors.foreground }]}
|
|
placeholder="What do you want to post?"
|
|
placeholderTextColor={colors.mutedForeground}
|
|
multiline
|
|
value={content}
|
|
onChangeText={setContent}
|
|
maxLength={effectiveCharLimit}
|
|
textAlignVertical="top"
|
|
/>
|
|
<View style={styles.charCountRow}>
|
|
{effectiveCharLimit < 3000 && selectedChannels.length > 0 && (
|
|
<Text style={[styles.charCountLabel, { color: colors.mutedForeground }]}>
|
|
limit: {effectiveCharLimit}
|
|
</Text>
|
|
)}
|
|
<Text
|
|
style={[
|
|
styles.charCount,
|
|
{
|
|
color:
|
|
content.length >= effectiveCharLimit
|
|
? colors.error
|
|
: content.length > effectiveCharLimit * 0.9
|
|
? colors.warning
|
|
: colors.mutedForeground,
|
|
},
|
|
]}
|
|
>
|
|
{content.length}/{effectiveCharLimit}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{mediaItems.length > 0 && (
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={styles.imageRow}
|
|
>
|
|
{mediaItems.map((item, idx) => {
|
|
const uri =
|
|
item.type === "local"
|
|
? item.uri
|
|
: resolveMediaUrl(item.path, workspaces.find((w) => w.id === item.workspaceId)?.baseUrl ?? "");
|
|
return (
|
|
<View key={idx} style={styles.imageThumbWrap}>
|
|
<Image
|
|
source={{ uri }}
|
|
style={[styles.imageThumb, { borderColor: colors.border }]}
|
|
contentFit="cover"
|
|
/>
|
|
<TouchableOpacity
|
|
onPress={() => removeMediaItem(idx)}
|
|
style={[styles.removeImg, { backgroundColor: colors.destructive }]}
|
|
>
|
|
<Feather name="x" size={12} color="#fff" />
|
|
</TouchableOpacity>
|
|
{item.type === "uploaded" && (
|
|
<View style={[styles.uploadedBadge, { backgroundColor: colors.success }]}>
|
|
<Feather name="cloud" size={8} color="#fff" />
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
})}
|
|
</ScrollView>
|
|
)}
|
|
|
|
{mediaItems.length < MAX_IMAGES && (
|
|
<View style={styles.mediaBtnsRow}>
|
|
<TouchableOpacity
|
|
onPress={pickImage}
|
|
activeOpacity={0.7}
|
|
style={[styles.mediaBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
|
|
>
|
|
<Feather name="image" size={16} color={colors.mutedForeground} />
|
|
<Text style={[styles.mediaBtnText, { color: colors.mutedForeground }]}>
|
|
{mediaItems.length === 0 ? "Add image" : "Add more"}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
onPress={() => setShowMediaLibrary(true)}
|
|
activeOpacity={0.7}
|
|
style={[styles.mediaBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
|
|
>
|
|
<Feather name="folder" size={16} color={colors.mutedForeground} />
|
|
<Text style={[styles.mediaBtnText, { color: colors.mutedForeground }]}>Library</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
)}
|
|
|
|
{/* Channels grouped by workspace then network type */}
|
|
<Text style={[styles.sectionLabel, { color: colors.mutedForeground }]}>CHANNELS</Text>
|
|
|
|
{loadingIntegrations ? (
|
|
<ActivityIndicator color={colors.primary} style={{ marginVertical: 8 }} />
|
|
) : grouped.length === 0 ? (
|
|
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
|
|
No channels found. Add integrations in your Postiz instance.
|
|
</Text>
|
|
) : (
|
|
<View style={styles.channelGroups}>
|
|
{grouped.map(({ workspace, networks }, wsIdx) => (
|
|
<View
|
|
key={workspace.id}
|
|
style={[
|
|
styles.workspaceSection,
|
|
{
|
|
backgroundColor: colors.card,
|
|
borderColor: colors.border,
|
|
},
|
|
wsIdx > 0 && { marginTop: 8 },
|
|
]}
|
|
>
|
|
{/* Workspace header */}
|
|
<View style={[styles.workspaceHeader, { borderBottomColor: colors.border }]}>
|
|
<Feather name="briefcase" size={12} color={colors.primary} />
|
|
<Text style={[styles.workspaceName, { color: colors.primary }]}>
|
|
{workspace.name}
|
|
</Text>
|
|
</View>
|
|
|
|
{/* Network groups */}
|
|
<View style={styles.networkGroups}>
|
|
{networks.map(({ label, channels }) => (
|
|
<View key={label} style={styles.networkGroup}>
|
|
{networks.length > 1 && (
|
|
<Text style={[styles.networkLabel, { color: colors.mutedForeground }]}>
|
|
{label}
|
|
</Text>
|
|
)}
|
|
<View style={styles.chipRow}>
|
|
{channels.map((intg) => (
|
|
<ChannelChip
|
|
key={intg.id}
|
|
integration={intg}
|
|
selected={selectedChannels.includes(intg.id)}
|
|
onToggle={() => toggleChannel(intg.id)}
|
|
/>
|
|
))}
|
|
</View>
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
))}
|
|
</View>
|
|
)}
|
|
|
|
<View style={[styles.scheduleRow, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
|
<View style={styles.scheduleRowLeft}>
|
|
<Feather name="zap" size={16} color={colors.primary} />
|
|
<Text style={[styles.scheduleLabel, { color: colors.foreground }]}>Post now</Text>
|
|
</View>
|
|
<Switch
|
|
value={postNow}
|
|
onValueChange={setPostNow}
|
|
trackColor={{ false: colors.muted, true: colors.primary }}
|
|
thumbColor={colors.primaryForeground}
|
|
/>
|
|
</View>
|
|
|
|
{!postNow && (
|
|
<View style={styles.dateTimeRow}>
|
|
<TouchableOpacity
|
|
onPress={() => { setShowTimePicker(false); setShowDatePicker((v) => !v); }}
|
|
style={[styles.dateBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Feather name="calendar" size={14} color={colors.primary} />
|
|
<Text style={[styles.dateBtnText, { color: colors.foreground }]}>
|
|
{formatDateLabel(scheduleDate)}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
onPress={() => { setShowDatePicker(false); setShowTimePicker((v) => !v); }}
|
|
style={[styles.dateBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Feather name="clock" size={14} color={colors.primary} />
|
|
<Text style={[styles.dateBtnText, { color: colors.foreground }]}>
|
|
{formatTimeLabel(scheduleDate)}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
)}
|
|
|
|
{showDatePicker && (
|
|
<DateTimePicker
|
|
value={scheduleDate}
|
|
mode="date"
|
|
display={Platform.OS === "ios" ? "spinner" : "default"}
|
|
minimumDate={new Date()}
|
|
textColor={colors.foreground}
|
|
accentColor={colors.primary}
|
|
onChange={(_: unknown, date?: Date) => {
|
|
if (Platform.OS !== "ios") setShowDatePicker(false);
|
|
if (date) {
|
|
const merged = new Date(scheduleDate);
|
|
merged.setFullYear(date.getFullYear(), date.getMonth(), date.getDate());
|
|
setScheduleDate(merged);
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{showTimePicker && (
|
|
<DateTimePicker
|
|
value={scheduleDate}
|
|
mode="time"
|
|
display={Platform.OS === "ios" ? "spinner" : "default"}
|
|
textColor={colors.foreground}
|
|
accentColor={colors.primary}
|
|
onChange={(_: unknown, date?: Date) => {
|
|
if (Platform.OS !== "ios") setShowTimePicker(false);
|
|
if (date) {
|
|
const merged = new Date(scheduleDate);
|
|
merged.setHours(date.getHours(), date.getMinutes());
|
|
setScheduleDate(merged);
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<TouchableOpacity
|
|
onPress={saveDraft}
|
|
activeOpacity={0.7}
|
|
disabled={submitting || uploading || !content.trim()}
|
|
style={[styles.draftBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
|
|
>
|
|
<Feather name="file-text" size={14} color={colors.mutedForeground} />
|
|
<Text style={[styles.draftBtnText, { color: colors.mutedForeground }]}>Save Draft</Text>
|
|
</TouchableOpacity>
|
|
|
|
<TouchableOpacity
|
|
onPress={handleSubmit}
|
|
activeOpacity={0.85}
|
|
disabled={submitting || uploading}
|
|
style={[
|
|
styles.submitBtn,
|
|
{ backgroundColor: submitting || uploading ? colors.muted : colors.primary },
|
|
]}
|
|
>
|
|
{submitting || uploading ? (
|
|
<ActivityIndicator color={colors.primaryForeground} size="small" />
|
|
) : (
|
|
<>
|
|
<Feather name={postNow ? "send" : "clock"} size={16} color={colors.primaryForeground} />
|
|
<Text style={[styles.submitText, { color: colors.primaryForeground }]}>
|
|
{postNow ? "Publish Now" : "Schedule Post"}
|
|
</Text>
|
|
</>
|
|
)}
|
|
</TouchableOpacity>
|
|
</KeyboardAwareScrollView>
|
|
|
|
<MediaLibraryModal
|
|
visible={showMediaLibrary}
|
|
workspaces={workspaces}
|
|
maxSelect={MAX_IMAGES - mediaItems.length}
|
|
onClose={() => setShowMediaLibrary(false)}
|
|
onSelect={(items: LibraryMediaItem[]) => {
|
|
setMediaItems((prev) =>
|
|
[
|
|
...prev,
|
|
...items.map((i): MediaItem => ({
|
|
type: "uploaded",
|
|
id: i.id,
|
|
path: i.path,
|
|
workspaceId: i.workspaceId,
|
|
})),
|
|
].slice(0, MAX_IMAGES)
|
|
);
|
|
setShowMediaLibrary(false);
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: { paddingHorizontal: 16, gap: 14 },
|
|
centered: { flex: 1, alignItems: "center", justifyContent: "center", gap: 10 },
|
|
textArea: { borderRadius: 14, borderWidth: 1, padding: 14, minHeight: 140 },
|
|
textInput: { fontSize: 15, fontFamily: "Inter_400Regular", lineHeight: 22, minHeight: 100 },
|
|
charCountRow: { flexDirection: "row", justifyContent: "flex-end", alignItems: "center", gap: 6, marginTop: 4 },
|
|
charCountLabel: { fontSize: 10, fontFamily: "Inter_400Regular" },
|
|
charCount: { fontSize: 11, fontFamily: "Inter_400Regular" },
|
|
draftBanner: {
|
|
flexDirection: "row", alignItems: "center", gap: 8,
|
|
paddingHorizontal: 14, paddingVertical: 10, borderRadius: 12, borderWidth: 1,
|
|
},
|
|
draftBannerText: { flex: 1, fontSize: 13, fontFamily: "Inter_400Regular" },
|
|
draftBannerAction: { fontSize: 13, fontFamily: "Inter_600SemiBold" },
|
|
draftBtn: {
|
|
flexDirection: "row", alignItems: "center", justifyContent: "center",
|
|
gap: 6, paddingVertical: 10, borderRadius: 12, borderWidth: 1,
|
|
},
|
|
draftBtnText: { fontSize: 13, fontFamily: "Inter_500Medium" },
|
|
imageRow: { gap: 10, paddingRight: 4 },
|
|
imageThumbWrap: { position: "relative" },
|
|
imageThumb: { width: 100, height: 100, borderRadius: 10, borderWidth: 1 },
|
|
removeImg: {
|
|
position: "absolute", top: 4, right: 4,
|
|
width: 20, height: 20, borderRadius: 10, alignItems: "center", justifyContent: "center",
|
|
},
|
|
uploadedBadge: {
|
|
position: "absolute", bottom: 4, left: 4,
|
|
width: 16, height: 16, borderRadius: 8, alignItems: "center", justifyContent: "center",
|
|
},
|
|
mediaBtnsRow: { flexDirection: "row", gap: 8 },
|
|
mediaBtn: {
|
|
flexDirection: "row", alignItems: "center", gap: 8,
|
|
paddingHorizontal: 14, paddingVertical: 10, borderRadius: 10, borderWidth: 1,
|
|
},
|
|
mediaBtnText: { fontSize: 13, fontFamily: "Inter_500Medium" },
|
|
sectionLabel: { fontSize: 11, fontFamily: "Inter_600SemiBold", letterSpacing: 0.8, marginBottom: -6 },
|
|
sectionTitle: { fontSize: 18, fontFamily: "Inter_600SemiBold" },
|
|
hint: { fontSize: 13, fontFamily: "Inter_400Regular", textAlign: "center" },
|
|
channelGroups: { gap: 0 },
|
|
workspaceSection: { borderRadius: 14, borderWidth: 1, overflow: "hidden" },
|
|
workspaceHeader: {
|
|
flexDirection: "row", alignItems: "center", gap: 6,
|
|
paddingHorizontal: 12, paddingVertical: 8,
|
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
},
|
|
workspaceName: { fontSize: 11, fontFamily: "Inter_600SemiBold", letterSpacing: 0.5 },
|
|
networkGroups: { padding: 10, gap: 10 },
|
|
networkGroup: { gap: 4 },
|
|
networkLabel: { fontSize: 10, fontFamily: "Inter_500Medium", letterSpacing: 0.4, marginLeft: 2 },
|
|
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
|
|
scheduleRow: {
|
|
flexDirection: "row", alignItems: "center", justifyContent: "space-between",
|
|
paddingHorizontal: 16, paddingVertical: 12, borderRadius: 12, borderWidth: 1,
|
|
},
|
|
scheduleRowLeft: { flexDirection: "row", alignItems: "center", gap: 10 },
|
|
scheduleLabel: { fontSize: 15, fontFamily: "Inter_500Medium" },
|
|
dateTimeRow: { flexDirection: "row", gap: 10 },
|
|
dateBtn: {
|
|
flex: 1, flexDirection: "row", alignItems: "center", gap: 8,
|
|
paddingHorizontal: 12, paddingVertical: 10, borderRadius: 10, borderWidth: 1,
|
|
},
|
|
dateBtnText: { fontSize: 13, fontFamily: "Inter_500Medium" },
|
|
submitBtn: {
|
|
flexDirection: "row", alignItems: "center", justifyContent: "center",
|
|
gap: 8, paddingVertical: 14, borderRadius: 14, marginTop: 4,
|
|
},
|
|
submitText: { fontSize: 15, fontFamily: "Inter_600SemiBold" },
|
|
});
|