feat: multi-workspace support + channels grouped by workspace and network
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>
This commit is contained in:
2026-06-11 14:50:20 +02:00
parent d4c16ccf97
commit 8b7a2eb644
4 changed files with 743 additions and 542 deletions
+286 -113
View File
@@ -8,7 +8,7 @@ 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, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
Alert,
@@ -24,8 +24,13 @@ import {
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ChannelChip } from "@/components/ChannelChip";
import { MediaLibraryModal } from "@/components/MediaLibraryModal";
import { PostizIntegration, PostizUploadResult, usePostiz } from "@/context/PostizContext";
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";
@@ -40,9 +45,33 @@ const NETWORK_CHAR_LIMITS: Record<string, number> = {
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 };
| { 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;
@@ -53,7 +82,7 @@ function resolveMediaUrl(path: string, baseUrl: string): string {
export default function ComposeScreen() {
const colors = useColors();
const insets = useSafeAreaInsets();
const { client, isConfigured, apiKey, baseUrl } = usePostiz();
const { workspaces, clients, isConfigured } = usePostiz();
const queryClient = useQueryClient();
const { prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId } =
useLocalSearchParams<{
@@ -83,9 +112,11 @@ export default function ComposeScreen() {
setSelectedChannels(String(prefillIntegrationIds).split(",").filter(Boolean));
}
if (prefillImagePath && prefillImageId) {
setMediaItems([{ type: "uploaded", id: String(prefillImageId), path: String(prefillImagePath) }]);
// 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]);
}, [prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId, workspaces]);
useEffect(() => {
if (prefillContent) return;
@@ -98,21 +129,61 @@ export default function ComposeScreen() {
});
}, [prefillContent]);
const { data: integrations, isLoading: loadingIntegrations } =
useQuery<PostizIntegration[]>({
queryKey: ["integrations", !!client],
// 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 () => {
if (!client) return [];
const res = await client.get("integrations");
return Array.isArray(res.data) ? res.data : res.data?.integrations ?? [];
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: !!client,
enabled: workspaces.length > 0 && Object.keys(clients).length > 0,
staleTime: 60000,
});
const effectiveCharLimit = (() => {
if (selectedChannels.length === 0 || !integrations) return 3000;
const selected = integrations.filter((i) => selectedChannels.includes(i.id));
// 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)) {
@@ -121,7 +192,7 @@ export default function ComposeScreen() {
return 3000;
});
return Math.min(...limits);
})();
}, [selectedChannels, allIntegrations]);
const saveDraft = async () => {
const draft = { content, integrationIds: selectedChannels };
@@ -193,44 +264,39 @@ export default function ComposeScreen() {
setMediaItems((prev) => prev.filter((_, i) => i !== index));
};
const buildMediaPayload = async (): Promise<Array<{ id: string; path: string }>> => {
setUploading(true);
try {
const result: Array<{ id: string; path: string }> = [];
for (const item of mediaItems) {
if (item.type === "uploaded") {
result.push({ id: item.id, path: item.path });
continue;
}
const formData = new FormData();
if (Platform.OS === "web") {
const response = await expoFetch(item.uri);
const blob = await response.blob();
formData.append("file", blob, "upload.jpg");
} else {
formData.append("file", {
uri: item.uri,
name: "upload.jpg",
type: "image/jpeg",
} as unknown as Blob);
}
// eslint-disable-next-line no-undef
const uploadRes = await globalThis.fetch(`${baseUrl}/upload`, {
method: "POST",
headers: { Authorization: apiKey },
body: formData,
});
if (!uploadRes.ok) {
const raw = await uploadRes.text().catch(() => uploadRes.statusText);
throw new Error(`Upload HTTP ${uploadRes.status}: ${raw.slice(0, 200)}`);
}
const uploaded = (await uploadRes.json()) as PostizUploadResult;
result.push({ id: uploaded.id, path: uploaded.path });
// 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);
}
return result;
} finally {
setUploading(false);
// 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 () => {
@@ -245,40 +311,71 @@ export default function ComposeScreen() {
}
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
setSubmitting(true);
try {
const media = mediaItems.length > 0 ? await buildMediaPayload() : [];
const payload = {
type: postNow ? "now" : "schedule",
date: postNow ? new Date().toISOString() : scheduleDate.toISOString(),
shortLink: false,
tags: [] as string[],
posts: selectedChannels.map((integrationId) => ({
integration: { id: integrationId },
value: [{ content: content.trim(), image: media }],
})),
};
const body = JSON.stringify(payload);
console.log("[compose] POST", `${baseUrl}/posts`, body);
// eslint-disable-next-line no-undef
const res = await globalThis.fetch(`${baseUrl}/posts`, {
method: "POST",
headers: { Authorization: apiKey, "Content-Type": "application/json" },
body,
});
if (!res.ok) {
let detail = "";
try {
const raw = await res.text();
console.log("[compose] error body:", raw);
detail = raw.slice(0, 500);
} catch {
detail = res.statusText;
// 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: [] });
}
throw new Error(`HTTP ${res.status}: ${detail}`);
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(
@@ -289,6 +386,7 @@ export default function ComposeScreen() {
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);
@@ -317,7 +415,7 @@ export default function ComposeScreen() {
<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 your API key in Settings</Text>
<Text style={[styles.hint, { color: colors.mutedForeground }]}>Add a workspace in Settings</Text>
</View>
);
}
@@ -395,7 +493,7 @@ export default function ComposeScreen() {
const uri =
item.type === "local"
? item.uri
: resolveMediaUrl(item.path, baseUrl);
: resolveMediaUrl(item.path, workspaces.find((w) => w.id === item.workspaceId)?.baseUrl ?? "");
return (
<View key={idx} style={styles.imageThumbWrap}>
<Image
@@ -443,29 +541,62 @@ export default function ComposeScreen() {
</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 }} />
) : (integrations ?? []).length === 0 ? (
) : grouped.length === 0 ? (
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
No channels found. Add integrations in your Postiz instance.
</Text>
) : (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.channelList}
>
{(integrations ?? []).map((intg) => (
<ChannelChip
key={intg.id}
integration={intg}
selected={selectedChannels.includes(intg.id)}
onToggle={() => toggleChannel(intg.id)}
/>
<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>
))}
</ScrollView>
</View>
)}
<View style={[styles.scheduleRow, { backgroundColor: colors.card, borderColor: colors.border }]}>
@@ -577,13 +708,20 @@ export default function ComposeScreen() {
<MediaLibraryModal
visible={showMediaLibrary}
baseUrl={baseUrl}
apiKey={apiKey}
workspaces={workspaces}
maxSelect={MAX_IMAGES - mediaItems.length}
onClose={() => setShowMediaLibrary(false)}
onSelect={(items) => {
onSelect={(items: LibraryMediaItem[]) => {
setMediaItems((prev) =>
[...prev, ...items.map((i): MediaItem => ({ type: "uploaded", id: i.id, path: i.path }))].slice(0, MAX_IMAGES)
[
...prev,
...items.map((i): MediaItem => ({
type: "uploaded",
id: i.id,
path: i.path,
workspaceId: i.workspaceId,
})),
].slice(0, MAX_IMAGES)
);
setShowMediaLibrary(false);
}}
@@ -600,29 +738,64 @@ const styles = StyleSheet.create({
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 },
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 },
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" },
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 },
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" },
channelList: { flexDirection: "row", gap: 8, flexWrap: "wrap" },
scheduleRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 16, paddingVertical: 12, borderRadius: 12, borderWidth: 1 },
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 },
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 },
submitBtn: {
flexDirection: "row", alignItems: "center", justifyContent: "center",
gap: 8, paddingVertical: 14, borderRadius: 14, marginTop: 4,
},
submitText: { fontSize: 15, fontFamily: "Inter_600SemiBold" },
});
+279 -288
View File
@@ -1,7 +1,7 @@
import { Feather } from "@expo/vector-icons";
import axios from "axios";
import * as Haptics from "expo-haptics";
import React, { useEffect, useState } from "react";
import React, { useState } from "react";
import {
ActivityIndicator,
Alert,
@@ -15,49 +15,73 @@ import {
} from "react-native";
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { usePostiz, DEFAULT_BASE_URL } from "@/context/PostizContext";
import { PostizWorkspace, DEFAULT_BASE_URL, usePostiz } from "@/context/PostizContext";
import { useColors } from "@/hooks/useColors";
import { extractError } from "@/lib/extractError";
type FormState = {
id?: string;
name: string;
url: string;
key: string;
};
const EMPTY_FORM: FormState = { name: "", url: DEFAULT_BASE_URL, key: "" };
export default function SettingsScreen() {
const colors = useColors();
const insets = useSafeAreaInsets();
const { apiKey, baseUrl, isConfigured, saveSettings, clearSettings } = usePostiz();
const { workspaces, isConfigured, addWorkspace, updateWorkspace, removeWorkspace } = usePostiz();
const [inputKey, setInputKey] = useState(apiKey);
const [inputUrl, setInputUrl] = useState(baseUrl || DEFAULT_BASE_URL);
const [form, setForm] = useState<FormState | null>(null);
const [showKey, setShowKey] = useState(false);
const [validating, setValidating] = useState(false);
const [saving, setSaving] = useState(false);
const [validationStatus, setValidationStatus] = useState<"idle" | "ok" | "error">("idle");
const [errorDetail, setErrorDetail] = useState<string>("");
const [errorDetail, setErrorDetail] = useState("");
useEffect(() => {
setInputKey(apiKey);
setInputUrl(baseUrl || DEFAULT_BASE_URL);
}, [apiKey, baseUrl]);
const openAdd = () => {
setForm(EMPTY_FORM);
setShowKey(false);
resetValidation();
};
const openEdit = (ws: PostizWorkspace) => {
setForm({ id: ws.id, name: ws.name, url: ws.baseUrl, key: ws.apiKey });
setShowKey(false);
resetValidation();
};
const closeForm = () => {
setForm(null);
resetValidation();
};
const resetValidation = () => {
setValidationStatus("idle");
setErrorDetail("");
};
const patchForm = (patch: Partial<FormState>) => {
setForm((prev) => (prev ? { ...prev, ...patch } : prev));
resetValidation();
};
const handleValidate = async () => {
if (!inputKey.trim() || !inputUrl.trim()) {
if (!form?.key.trim() || !form?.url.trim()) {
Alert.alert("Missing fields", "Please enter both API key and base URL.");
return;
}
setValidating(true);
setValidationStatus("idle");
setErrorDetail("");
const cleanUrl = inputUrl.trim().replace(/\/$/, "");
resetValidation();
const cleanUrl = form.url.trim().replace(/\/$/, "");
const variants = [form.key.trim(), `Bearer ${form.key.trim()}`];
let lastError = "";
const authVariants = [
inputKey.trim(),
`Bearer ${inputKey.trim()}`,
];
let lastError: string = "";
for (const authHeader of authVariants) {
for (const auth of variants) {
try {
await axios.get(`${cleanUrl}/integrations`, {
headers: { Authorization: authHeader },
headers: { Authorization: auth },
timeout: 10000,
maxRedirects: 0,
});
@@ -67,21 +91,17 @@ export default function SettingsScreen() {
return;
} catch (err: unknown) {
if (axios.isAxiosError(err)) {
const status = err.response?.status;
if (status === 307 || status === 301 || status === 302 || status === 308) {
const location = err.response?.headers?.location ?? "unknown";
lastError = `HTTP ${status} redirect → ${location}. The API rejected the request and redirected to login. Check the Authorization header format or the base URL.`;
continue;
}
if (status === 401 || status === 403) {
lastError = `HTTP ${status}: Invalid or expired API key.`;
const s = err.response?.status;
if (s === 307 || s === 301 || s === 302 || s === 308) {
const loc = err.response?.headers?.location ?? "unknown";
lastError = `HTTP ${s} redirect → ${loc}. Check the Authorization format or base URL.`;
continue;
}
if (s === 401 || s === 403) { lastError = `HTTP ${s}: Invalid or expired API key.`; continue; }
}
lastError = extractError(err);
}
}
setErrorDetail(lastError);
setValidationStatus("error");
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
@@ -89,37 +109,38 @@ export default function SettingsScreen() {
};
const handleSave = async () => {
if (!inputKey.trim() || !inputUrl.trim()) {
Alert.alert("Missing fields", "Please enter both API key and base URL.");
return;
}
if (!form) return;
if (!form.name.trim()) { Alert.alert("Missing name", "Please enter a name for this workspace."); return; }
if (!form.key.trim() || !form.url.trim()) { Alert.alert("Missing fields", "Please enter both API key and base URL."); return; }
setSaving(true);
try {
await saveSettings(inputKey.trim(), inputUrl.trim().replace(/\/$/, ""));
const ws = { name: form.name.trim(), apiKey: form.key.trim(), baseUrl: form.url.trim().replace(/\/$/, "") };
if (form.id) {
await updateWorkspace({ ...ws, id: form.id });
} else {
await addWorkspace(ws);
}
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
Alert.alert("Saved", "Settings saved successfully.");
closeForm();
} catch (err: unknown) {
Alert.alert("Error", `Failed to save settings.\n${extractError(err)}`);
Alert.alert("Error", `Failed to save.\n${extractError(err)}`);
} finally {
setSaving(false);
}
};
const handleClear = () => {
const handleDelete = (ws: PostizWorkspace) => {
Alert.alert(
"Disconnect",
"Remove your API key and disconnect from Postiz?",
"Remove workspace",
`Remove "${ws.name}"? Channels from this workspace will no longer be available.`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Disconnect",
text: "Remove",
style: "destructive",
onPress: async () => {
await clearSettings();
setInputKey("");
setInputUrl(DEFAULT_BASE_URL);
setValidationStatus("idle");
setErrorDetail("");
if (form?.id === ws.id) closeForm();
await removeWorkspace(ws.id);
},
},
]
@@ -140,281 +161,251 @@ export default function SettingsScreen() {
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{!isConfigured && (
{/* Status banner */}
{!isConfigured ? (
<View style={[styles.banner, { backgroundColor: colors.primary + "18", borderColor: colors.primary + "40" }]}>
<Feather name="info" size={16} color={colors.primary} />
<Text style={[styles.bannerText, { color: colors.primary }]}>
Connect to your Postiz instance to get started
Add a workspace to get started
</Text>
</View>
)}
{isConfigured && (
) : (
<View style={[styles.connectedBadge, { backgroundColor: colors.success + "18", borderColor: colors.success + "40" }]}>
<Feather name="check-circle" size={14} color={colors.success} />
<Text style={[styles.connectedText, { color: colors.success }]}>
Connected to Postiz
{workspaces.length} workspace{workspaces.length > 1 ? "s" : ""} configured
</Text>
</View>
)}
<View style={styles.section}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>BASE URL</Text>
<View style={[styles.inputWrap, { backgroundColor: colors.card, borderColor: colors.border }]}>
<Feather name="globe" size={16} color={colors.mutedForeground} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.foreground }]}
placeholder="https://postiz.example.com/api/public/v1"
placeholderTextColor={colors.mutedForeground}
value={inputUrl}
onChangeText={(t) => { setInputUrl(t); setValidationStatus("idle"); setErrorDetail(""); }}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
</View>
</View>
<View style={styles.section}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>API KEY</Text>
<View style={[styles.inputWrap, { backgroundColor: colors.card, borderColor: colors.border }]}>
<Feather name="key" size={16} color={colors.mutedForeground} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.foreground }]}
placeholder="Enter your API key"
placeholderTextColor={colors.mutedForeground}
value={inputKey}
onChangeText={(t) => { setInputKey(t); setValidationStatus("idle"); setErrorDetail(""); }}
secureTextEntry={!showKey}
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity onPress={() => setShowKey((v) => !v)} activeOpacity={0.7}>
<Feather name={showKey ? "eye-off" : "eye"} size={16} color={colors.mutedForeground} />
</TouchableOpacity>
</View>
{validationStatus === "ok" && (
<View style={styles.validationRow}>
<Feather name="check-circle" size={13} color={colors.success} />
<Text style={[styles.validationText, { color: colors.success }]}>
Connection successful
</Text>
</View>
)}
{validationStatus === "error" && (
<View style={[styles.errorBox, { backgroundColor: colors.error + "12", borderColor: colors.error + "30" }]}>
<View style={styles.errorHeader}>
<Feather name="x-circle" size={13} color={colors.error} />
<Text style={[styles.errorTitle, { color: colors.error }]}>
Could not connect
</Text>
</View>
{!!errorDetail && (
<ScrollView style={styles.errorScroll} nestedScrollEnabled>
<Text style={[styles.errorDetail, { color: colors.error }]} selectable>
{errorDetail}
{/* Workspace cards */}
{workspaces.map((ws) => (
<View key={ws.id} style={[styles.wsCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
<View style={styles.wsCardHeader}>
<View style={styles.wsCardLeft}>
<View style={[styles.wsIcon, { backgroundColor: colors.primary + "18" }]}>
<Feather name="briefcase" size={14} color={colors.primary} />
</View>
<View>
<Text style={[styles.wsName, { color: colors.foreground }]}>{ws.name}</Text>
<Text style={[styles.wsUrl, { color: colors.mutedForeground }]} numberOfLines={1}>
{ws.baseUrl.replace(/^https?:\/\//, "").replace(/\/api.*$/, "")}
</Text>
</ScrollView>
)}
</View>
</View>
<View style={styles.wsCardActions}>
<TouchableOpacity onPress={() => openEdit(ws)} activeOpacity={0.7} style={styles.iconBtn}>
<Feather name="edit-2" size={15} color={colors.mutedForeground} />
</TouchableOpacity>
<TouchableOpacity onPress={() => handleDelete(ws)} activeOpacity={0.7} style={styles.iconBtn}>
<Feather name="trash-2" size={15} color={colors.destructive} />
</TouchableOpacity>
</View>
</View>
)}
</View>
</View>
))}
<TouchableOpacity
onPress={handleValidate}
activeOpacity={0.8}
disabled={validating}
style={[styles.validateBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
>
{validating ? (
<ActivityIndicator color={colors.primary} size="small" />
) : (
<>
<Feather name="wifi" size={15} color={colors.primary} />
<Text style={[styles.validateText, { color: colors.primary }]}>
Test Connection
</Text>
</>
)}
</TouchableOpacity>
<TouchableOpacity
onPress={handleSave}
activeOpacity={0.85}
disabled={saving}
style={[styles.saveBtn, { backgroundColor: saving ? colors.muted : colors.primary }]}
>
{saving ? (
<ActivityIndicator color={colors.primaryForeground} size="small" />
) : (
<>
<Feather name="save" size={15} color={colors.primaryForeground} />
<Text style={[styles.saveText, { color: colors.primaryForeground }]}>
Save Settings
</Text>
</>
)}
</TouchableOpacity>
{isConfigured && (
{/* Add workspace button */}
{!form && (
<TouchableOpacity
onPress={handleClear}
onPress={openAdd}
activeOpacity={0.8}
style={[styles.clearBtn, { borderColor: colors.destructive + "60" }]}
style={[styles.addBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
>
<Feather name="log-out" size={14} color={colors.destructive} />
<Text style={[styles.clearText, { color: colors.destructive }]}>Disconnect</Text>
<Feather name="plus" size={16} color={colors.primary} />
<Text style={[styles.addBtnText, { color: colors.primary }]}>Add workspace</Text>
</TouchableOpacity>
)}
<View style={styles.footer}>
<Text style={[styles.footerText, { color: colors.mutedForeground }]}>
Your API key is stored securely on this device and never transmitted to third parties.
</Text>
</View>
{/* Add / Edit form */}
{form && (
<View style={[styles.formCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
<Text style={[styles.formTitle, { color: colors.foreground }]}>
{form.id ? "Edit workspace" : "Add workspace"}
</Text>
{/* Name */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>NAME</Text>
<View style={[styles.inputWrap, { backgroundColor: colors.background, borderColor: colors.border }]}>
<Feather name="briefcase" size={15} color={colors.mutedForeground} />
<TextInput
style={[styles.input, { color: colors.foreground }]}
placeholder="My Client"
placeholderTextColor={colors.mutedForeground}
value={form.name}
onChangeText={(t) => patchForm({ name: t })}
autoCorrect={false}
/>
</View>
</View>
{/* Base URL */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>BASE URL</Text>
<View style={[styles.inputWrap, { backgroundColor: colors.background, borderColor: colors.border }]}>
<Feather name="globe" size={15} color={colors.mutedForeground} />
<TextInput
style={[styles.input, { color: colors.foreground }]}
placeholder="https://postiz.example.com/api/public/v1"
placeholderTextColor={colors.mutedForeground}
value={form.url}
onChangeText={(t) => patchForm({ url: t })}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
</View>
</View>
{/* API Key */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>API KEY</Text>
<View style={[styles.inputWrap, { backgroundColor: colors.background, borderColor: colors.border }]}>
<Feather name="key" size={15} color={colors.mutedForeground} />
<TextInput
style={[styles.input, { color: colors.foreground }]}
placeholder="Enter your API key"
placeholderTextColor={colors.mutedForeground}
value={form.key}
onChangeText={(t) => patchForm({ key: t })}
secureTextEntry={!showKey}
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity onPress={() => setShowKey((v) => !v)} activeOpacity={0.7}>
<Feather name={showKey ? "eye-off" : "eye"} size={15} color={colors.mutedForeground} />
</TouchableOpacity>
</View>
{validationStatus === "ok" && (
<View style={styles.validRow}>
<Feather name="check-circle" size={13} color={colors.success} />
<Text style={[styles.validText, { color: colors.success }]}>Connection successful</Text>
</View>
)}
{validationStatus === "error" && (
<View style={[styles.errorBox, { backgroundColor: colors.error + "12", borderColor: colors.error + "30" }]}>
<View style={styles.errorHeader}>
<Feather name="x-circle" size={13} color={colors.error} />
<Text style={[styles.errorTitle, { color: colors.error }]}>Could not connect</Text>
</View>
{!!errorDetail && (
<ScrollView style={styles.errorScroll} nestedScrollEnabled>
<Text style={[styles.errorDetail, { color: colors.error }]} selectable>{errorDetail}</Text>
</ScrollView>
)}
</View>
)}
</View>
{/* Form actions */}
<TouchableOpacity
onPress={handleValidate}
activeOpacity={0.8}
disabled={validating}
style={[styles.validateBtn, { backgroundColor: colors.background, borderColor: colors.border }]}
>
{validating ? (
<ActivityIndicator color={colors.primary} size="small" />
) : (
<>
<Feather name="wifi" size={15} color={colors.primary} />
<Text style={[styles.validateText, { color: colors.primary }]}>Test Connection</Text>
</>
)}
</TouchableOpacity>
<View style={styles.formBtnsRow}>
<TouchableOpacity
onPress={closeForm}
activeOpacity={0.8}
style={[styles.cancelBtn, { borderColor: colors.border }]}
>
<Text style={[styles.cancelText, { color: colors.mutedForeground }]}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleSave}
activeOpacity={0.85}
disabled={saving}
style={[styles.saveBtn, { backgroundColor: saving ? colors.muted : colors.primary }]}
>
{saving ? (
<ActivityIndicator color={colors.primaryForeground} size="small" />
) : (
<>
<Feather name="save" size={15} color={colors.primaryForeground} />
<Text style={[styles.saveText, { color: colors.primaryForeground }]}>
{form.id ? "Update" : "Save"}
</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
)}
<Text style={[styles.footerText, { color: colors.mutedForeground }]}>
API keys are stored securely on this device and never transmitted to third parties.
</Text>
</KeyboardAwareScrollView>
);
}
const styles = StyleSheet.create({
container: {
paddingHorizontal: 20,
gap: 16,
},
container: { paddingHorizontal: 20, gap: 14 },
banner: {
flexDirection: "row",
alignItems: "center",
gap: 10,
paddingHorizontal: 14,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
},
bannerText: {
fontSize: 13,
fontFamily: "Inter_500Medium",
flex: 1,
flexDirection: "row", alignItems: "center", gap: 10,
paddingHorizontal: 14, paddingVertical: 12, borderRadius: 12, borderWidth: 1,
},
bannerText: { fontSize: 13, fontFamily: "Inter_500Medium", flex: 1 },
connectedBadge: {
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 10,
borderWidth: 1,
alignSelf: "flex-start",
flexDirection: "row", alignItems: "center", gap: 6,
paddingHorizontal: 12, paddingVertical: 8, borderRadius: 10, borderWidth: 1, alignSelf: "flex-start",
},
connectedText: {
fontSize: 12,
fontFamily: "Inter_600SemiBold",
},
section: {
gap: 8,
},
label: {
fontSize: 11,
fontFamily: "Inter_600SemiBold",
letterSpacing: 0.8,
marginLeft: 2,
connectedText: { fontSize: 12, fontFamily: "Inter_600SemiBold" },
wsCard: { borderRadius: 14, borderWidth: 1, overflow: "hidden" },
wsCardHeader: { flexDirection: "row", alignItems: "center", paddingHorizontal: 14, paddingVertical: 12 },
wsCardLeft: { flex: 1, flexDirection: "row", alignItems: "center", gap: 12 },
wsIcon: { width: 32, height: 32, borderRadius: 10, alignItems: "center", justifyContent: "center" },
wsName: { fontSize: 14, fontFamily: "Inter_600SemiBold" },
wsUrl: { fontSize: 11, fontFamily: "Inter_400Regular", marginTop: 1 },
wsCardActions: { flexDirection: "row", gap: 4 },
iconBtn: { padding: 8 },
addBtn: {
flexDirection: "row", alignItems: "center", justifyContent: "center",
gap: 8, paddingVertical: 13, borderRadius: 14, borderWidth: 1, borderStyle: "dashed",
},
addBtnText: { fontSize: 14, fontFamily: "Inter_600SemiBold" },
formCard: { borderRadius: 14, borderWidth: 1, padding: 16, gap: 14 },
formTitle: { fontSize: 15, fontFamily: "Inter_600SemiBold" },
fieldGroup: { gap: 6 },
label: { fontSize: 11, fontFamily: "Inter_600SemiBold", letterSpacing: 0.8, marginLeft: 2 },
inputWrap: {
flexDirection: "row",
alignItems: "center",
borderRadius: 12,
borderWidth: 1,
paddingHorizontal: 14,
paddingVertical: 12,
gap: 10,
},
inputIcon: {
flexShrink: 0,
},
input: {
flex: 1,
fontSize: 14,
fontFamily: "Inter_400Regular",
},
validationRow: {
flexDirection: "row",
alignItems: "center",
gap: 6,
marginLeft: 2,
},
validationText: {
fontSize: 12,
fontFamily: "Inter_400Regular",
},
errorBox: {
borderRadius: 10,
borderWidth: 1,
padding: 12,
gap: 6,
},
errorHeader: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
errorTitle: {
fontSize: 12,
fontFamily: "Inter_600SemiBold",
},
errorScroll: {
maxHeight: 80,
},
errorDetail: {
fontSize: 11,
fontFamily: "Inter_400Regular",
lineHeight: 16,
flexDirection: "row", alignItems: "center",
borderRadius: 10, borderWidth: 1, paddingHorizontal: 12, paddingVertical: 11, gap: 10,
},
input: { flex: 1, fontSize: 14, fontFamily: "Inter_400Regular" },
validRow: { flexDirection: "row", alignItems: "center", gap: 6, marginLeft: 2 },
validText: { fontSize: 12, fontFamily: "Inter_400Regular" },
errorBox: { borderRadius: 10, borderWidth: 1, padding: 12, gap: 6 },
errorHeader: { flexDirection: "row", alignItems: "center", gap: 6 },
errorTitle: { fontSize: 12, fontFamily: "Inter_600SemiBold" },
errorScroll: { maxHeight: 80 },
errorDetail: { fontSize: 11, fontFamily: "Inter_400Regular", lineHeight: 16 },
validateBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
},
validateText: {
fontSize: 14,
fontFamily: "Inter_600SemiBold",
flexDirection: "row", alignItems: "center", justifyContent: "center",
gap: 8, paddingVertical: 11, borderRadius: 10, borderWidth: 1,
},
validateText: { fontSize: 14, fontFamily: "Inter_600SemiBold" },
formBtnsRow: { flexDirection: "row", gap: 10 },
cancelBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, borderWidth: 1, alignItems: "center" },
cancelText: { fontSize: 14, fontFamily: "Inter_500Medium" },
saveBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
paddingVertical: 14,
borderRadius: 14,
},
saveText: {
fontSize: 15,
fontFamily: "Inter_600SemiBold",
},
clearBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
},
clearText: {
fontSize: 14,
fontFamily: "Inter_500Medium",
},
footer: {
marginTop: 8,
},
footerText: {
fontSize: 12,
fontFamily: "Inter_400Regular",
textAlign: "center",
lineHeight: 18,
flex: 2, flexDirection: "row", alignItems: "center", justifyContent: "center",
gap: 8, paddingVertical: 12, borderRadius: 10,
},
saveText: { fontSize: 14, fontFamily: "Inter_600SemiBold" },
footerText: { fontSize: 12, fontFamily: "Inter_400Regular", textAlign: "center", lineHeight: 18, marginTop: 4 },
});