4 Commits

Author SHA1 Message Date
billisdead 8b7a2eb644 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>
2026-06-11 14:50:20 +02:00
billisdead d4c16ccf97 chore: translate release notes to English
Release APK / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:21:44 +02:00
billisdead 0696f5663e feat: multi-images, media library, + fix HTML in notifications
Release APK / build (push) Has been cancelled
Multi-images (compose):
- Replace single imageUri with mediaItems: MediaItem[] (local | uploaded)
- allowsMultipleSelection: true, selectionLimit up to 4 total
- Each picked image is resized to max 1920px before upload
- Thumbnail row with individual × remove buttons
- uploaded badge (cloud icon) on library/prefill images
- buildMediaPayload() uploads local items, passes uploaded items as-is

Media Library:
- New MediaLibraryModal component — full-screen modal
- Fetches GET /media from Postiz instance
- 3-column grid with multi-select (capped at remaining slots)
- Selected items added to compose media pool

Notifications:
- Strip HTML from notification body text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 17:09:08 +02:00
billisdead 4a531df8bd fix: strip HTML-encoded tags (decode entities before stripping)
Release APK / build (push) Has been cancelled
The previous stripHtml decoded &lt;/&gt; after the regex pass, so content
stored as &lt;p&gt;text&lt;/p&gt; was never stripped. Now entities are
decoded first, then all tags are removed.

Also strip HTML when prefilling compose from an existing post (Edit/Repost)
so the text field shows clean content, not raw markup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 17:01:15 +02:00
8 changed files with 1194 additions and 905 deletions
+3 -3
View File
@@ -106,11 +106,11 @@ jobs:
body: |
## Postiz Mobile ${{ github.ref_name }}
APK signé pour Android — installation directe (sideload).
Signed APK for Android — direct install (sideload).
### Installation
1. Activer "Sources inconnues" sur l'appareil
2. Transférer l'APK et ouvrir pour installer
1. Enable "Unknown sources" on the device
2. Transfer the APK to the device and open it to install
files: ${{ steps.apk.outputs.path }}
draft: false
prerelease: ${{ contains(github.ref_name, '-') }}
+347 -298
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,9 +24,17 @@ import {
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ChannelChip } from "@/components/ChannelChip";
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";
const MAX_IMAGES = 4;
const NETWORK_CHAR_LIMITS: Record<string, number> = {
twitter: 280, x: 280,
@@ -37,10 +45,44 @@ 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; 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 { client, isConfigured, apiKey, baseUrl } = usePostiz();
const { workspaces, clients, isConfigured } = usePostiz();
const queryClient = useQueryClient();
const { prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId } =
useLocalSearchParams<{
@@ -49,6 +91,7 @@ export default function ComposeScreen() {
prefillImagePath?: string;
prefillImageId?: string;
}>();
const [content, setContent] = useState("");
const [selectedChannels, setSelectedChannels] = useState<string[]>([]);
const [postNow, setPostNow] = useState(false);
@@ -57,24 +100,23 @@ export default function ComposeScreen() {
);
const [showDatePicker, setShowDatePicker] = useState(false);
const [showTimePicker, setShowTimePicker] = useState(false);
const [imageUri, setImageUri] = useState<string | null>(null);
const [existingMedia, setExistingMedia] = useState<Array<{ id: string; path: string }>>([]);
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 (prefillContent) setContent(String(prefillContent));
if (prefillIntegrationIds) {
setSelectedChannels(String(prefillIntegrationIds).split(",").filter(Boolean));
}
if (prefillImagePath && prefillImageId) {
setExistingMedia([{ id: String(prefillImageId), path: String(prefillImagePath) }]);
setImageUri(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;
@@ -87,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 () => {
const results = await Promise.all(
workspaces.map(async (ws) => {
const client = clients[ws.id];
if (!client) return [];
const res = await client.get("integrations");
return Array.isArray(res.data) ? res.data : res.data?.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)) {
@@ -110,7 +192,7 @@ export default function ComposeScreen() {
return 3000;
});
return Math.min(...limits);
})();
}, [selectedChannels, allIntegrations]);
const saveDraft = async () => {
const draft = { content, integrationIds: selectedChannels };
@@ -130,8 +212,6 @@ export default function ComposeScreen() {
} catch {}
};
const dismissDraft = () => setDraftBanner(false);
const toggleChannel = (id: string) => {
setSelectedChannels((prev) =>
prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]
@@ -139,71 +219,84 @@ export default function ComposeScreen() {
};
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[0]) {
const asset = result.assets[0];
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;
const needsResize = w > MAX_DIM || h > MAX_DIM;
if (needsResize) {
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 }
);
setImageUri(resized.uri);
processed.push(resized.uri);
} else {
setImageUri(asset.uri);
processed.push(asset.uri);
}
setExistingMedia([]);
}
setMediaItems((prev) =>
[...prev, ...processed.map((uri): MediaItem => ({ type: "local", uri }))].slice(0, MAX_IMAGES)
);
}
};
const removeImage = () => {
setImageUri(null);
setExistingMedia([]);
const removeMediaItem = (index: number) => {
setMediaItems((prev) => prev.filter((_, i) => i !== index));
};
const uploadImage = async (): Promise<PostizUploadResult> => {
setUploading(true);
try {
// 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(imageUri!);
const response = await expoFetch(uri);
const blob = await response.blob();
formData.append("file", blob, "upload.jpg");
} else {
formData.append("file", {
uri: imageUri!,
uri,
name: "upload.jpg",
type: "image/jpeg",
} as unknown as Blob);
}
// eslint-disable-next-line no-undef
const uploadRes = await globalThis.fetch(`${baseUrl}/upload`, {
const uploadRes = await globalThis.fetch(`${ws.baseUrl}/upload`, {
method: "POST",
headers: { Authorization: apiKey },
headers: { Authorization: ws.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)}`);
throw new Error(`[${ws.name}] Upload HTTP ${uploadRes.status}: ${raw.slice(0, 200)}`);
}
return await uploadRes.json() as PostizUploadResult;
} finally {
setUploading(false);
const uploaded = (await uploadRes.json()) as PostizUploadResult;
result.push({ id: uploaded.id, path: uploaded.path });
}
return result;
};
const handleSubmit = async () => {
@@ -218,37 +311,55 @@ export default function ComposeScreen() {
}
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
setSubmitting(true);
try {
let media: Array<{ id: string; path: string }> = [];
const isLocalFile = imageUri && !imageUri.startsWith("http");
if (imageUri && isLocalFile) {
const uploaded = await uploadImage();
media = [{ id: uploaded.id, path: uploaded.path }];
} else if (existingMedia.length > 0) {
media = existingMedia;
// 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: selectedChannels.map((integrationId) => {
return {
posts: channelIds.map((integrationId) => ({
integration: { id: integrationId },
value: [{ content: content.trim(), image: media }],
})),
};
}),
};
const body = JSON.stringify(payload);
console.log("[compose] POST", `${baseUrl}/posts`, body);
console.log("[compose] POST", `${ws.baseUrl}/posts`, body);
// eslint-disable-next-line no-undef
const res = await globalThis.fetch(`${baseUrl}/posts`, {
const res = await globalThis.fetch(`${ws.baseUrl}/posts`, {
method: "POST",
headers: {
Authorization: apiKey,
"Content-Type": "application/json",
},
headers: { Authorization: ws.apiKey, "Content-Type": "application/json" },
body,
});
@@ -256,14 +367,15 @@ export default function ComposeScreen() {
let detail = "";
try {
const raw = await res.text();
console.log("[compose] 400 body:", raw);
console.log(`[compose][${ws.name}] error body:`, raw);
detail = raw.slice(0, 500);
} catch {
detail = res.statusText;
}
throw new Error(`HTTP ${res.status}: ${detail}`);
} 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(
@@ -274,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);
@@ -286,18 +399,13 @@ export default function ComposeScreen() {
setContent("");
setSelectedChannels([]);
setPostNow(false);
setImageUri(null);
setExistingMedia([]);
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",
});
d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
const formatTimeLabel = (d: Date) =>
d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
@@ -306,17 +414,14 @@ export default function ComposeScreen() {
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 your API key in Settings
</Text>
<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={[
@@ -333,24 +438,17 @@ export default function ComposeScreen() {
{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>
<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={dismissDraft} activeOpacity={0.7}>
<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 },
]}
>
<View style={[styles.textArea, { backgroundColor: colors.card, borderColor: colors.border }]}>
<TextInput
style={[styles.textInput, { color: colors.foreground }]}
placeholder="What do you want to post?"
@@ -385,53 +483,106 @@ export default function ComposeScreen() {
</View>
</View>
{imageUri && (
<View style={styles.imagePreviewWrap}>
{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: imageUri }}
style={[styles.imagePreview, { borderColor: colors.border }]}
source={{ uri }}
style={[styles.imageThumb, { borderColor: colors.border }]}
contentFit="cover"
/>
<TouchableOpacity
onPress={removeImage}
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 },
]}
style={[styles.mediaBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
>
<Feather name="image" size={16} color={colors.mutedForeground} />
<Text style={[styles.mediaBtnText, { color: colors.mutedForeground }]}>
{imageUri ? "Change image" : "Add image"}
{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>
)}
<Text style={[styles.sectionLabel, { color: colors.mutedForeground }]}>
CHANNELS
</Text>
{/* 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}
<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 },
]}
>
{(integrations ?? []).map((intg) => (
{/* 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}
@@ -439,20 +590,19 @@ export default function ComposeScreen() {
onToggle={() => toggleChannel(intg.id)}
/>
))}
</ScrollView>
</View>
</View>
))}
</View>
</View>
))}
</View>
)}
<View
style={[
styles.scheduleRow,
{ backgroundColor: colors.card, borderColor: colors.border },
]}
>
<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>
<Text style={[styles.scheduleLabel, { color: colors.foreground }]}>Post now</Text>
</View>
<Switch
value={postNow}
@@ -465,14 +615,8 @@ export default function ComposeScreen() {
{!postNow && (
<View style={styles.dateTimeRow}>
<TouchableOpacity
onPress={() => {
setShowTimePicker(false);
setShowDatePicker((v) => !v);
}}
style={[
styles.dateBtn,
{ backgroundColor: colors.card, borderColor: colors.border },
]}
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} />
@@ -481,14 +625,8 @@ export default function ComposeScreen() {
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
setShowDatePicker(false);
setShowTimePicker((v) => !v);
}}
style={[
styles.dateBtn,
{ backgroundColor: colors.card, borderColor: colors.border },
]}
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} />
@@ -552,21 +690,14 @@ export default function ComposeScreen() {
disabled={submitting || uploading}
style={[
styles.submitBtn,
{
backgroundColor:
submitting || uploading ? colors.muted : colors.primary,
},
{ 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}
/>
<Feather name={postNow ? "send" : "clock"} size={16} color={colors.primaryForeground} />
<Text style={[styles.submitText, { color: colors.primaryForeground }]}>
{postNow ? "Publish Now" : "Schedule Post"}
</Text>
@@ -574,179 +705,97 @@ export default function ComposeScreen() {
)}
</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",
},
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",
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",
},
imagePreviewWrap: {
position: "relative",
alignSelf: "flex-start",
},
imagePreview: {
width: 120,
height: 120,
borderRadius: 10,
borderWidth: 1,
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",
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,
alignSelf: "flex-start",
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",
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,
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",
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",
flexDirection: "row", alignItems: "center", justifyContent: "center",
gap: 8, paddingVertical: 14, borderRadius: 14, marginTop: 4,
},
submitText: { fontSize: 15, fontFamily: "Inter_600SemiBold" },
});
+1 -1
View File
@@ -155,7 +155,7 @@ export default function PostsScreen() {
router.push({
pathname: "/(tabs)/compose",
params: {
prefillContent: post.content,
prefillContent: stripHtml(post.content),
prefillIntegrationIds: integrations.map((i) => i.id).join(","),
},
});
+211 -220
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,34 +161,96 @@ 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}>
{/* 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>
</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>
))}
{/* Add workspace button */}
{!form && (
<TouchableOpacity
onPress={openAdd}
activeOpacity={0.8}
style={[styles.addBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
>
<Feather name="plus" size={16} color={colors.primary} />
<Text style={[styles.addBtnText, { color: colors.primary }]}>Add workspace</Text>
</TouchableOpacity>
)}
{/* 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.card, borderColor: colors.border }]}>
<Feather name="globe" size={16} color={colors.mutedForeground} style={styles.inputIcon} />
<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={inputUrl}
onChangeText={(t) => { setInputUrl(t); setValidationStatus("idle"); setErrorDetail(""); }}
value={form.url}
onChangeText={(t) => patchForm({ url: t })}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
@@ -175,31 +258,30 @@ export default function SettingsScreen() {
</View>
</View>
<View style={styles.section}>
{/* API Key */}
<View style={styles.fieldGroup}>
<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} />
<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={inputKey}
onChangeText={(t) => { setInputKey(t); setValidationStatus("idle"); setErrorDetail(""); }}
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={16} color={colors.mutedForeground} />
<Feather name={showKey ? "eye-off" : "eye"} size={15} color={colors.mutedForeground} />
</TouchableOpacity>
</View>
{validationStatus === "ok" && (
<View style={styles.validationRow}>
<View style={styles.validRow}>
<Feather name="check-circle" size={13} color={colors.success} />
<Text style={[styles.validationText, { color: colors.success }]}>
Connection successful
</Text>
<Text style={[styles.validText, { color: colors.success }]}>Connection successful</Text>
</View>
)}
@@ -207,39 +289,42 @@ export default function SettingsScreen() {
<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>
<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>
<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.card, borderColor: colors.border }]}
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>
<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}
@@ -252,169 +337,75 @@ export default function SettingsScreen() {
<>
<Feather name="save" size={15} color={colors.primaryForeground} />
<Text style={[styles.saveText, { color: colors.primaryForeground }]}>
Save Settings
{form.id ? "Update" : "Save"}
</Text>
</>
)}
</TouchableOpacity>
{isConfigured && (
<TouchableOpacity
onPress={handleClear}
activeOpacity={0.8}
style={[styles.clearBtn, { borderColor: colors.destructive + "60" }]}
>
<Feather name="log-out" size={14} color={colors.destructive} />
<Text style={[styles.clearText, { color: colors.destructive }]}>Disconnect</Text>
</TouchableOpacity>
</View>
</View>
)}
<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.
API keys are stored securely on this device and never transmitted to third parties.
</Text>
</View>
</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 },
});
@@ -0,0 +1,238 @@
import { Feather } from "@expo/vector-icons";
import { Image } from "expo-image";
import React, { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
FlatList,
Modal,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { PostizWorkspace } from "@/context/PostizContext";
import { useColors } from "@/hooks/useColors";
export interface LibraryMediaItem {
id: string;
path: string;
workspaceId: string;
createdAt?: string;
}
interface RawMediaItem {
id: string;
path: string;
createdAt?: string;
}
interface Props {
visible: boolean;
workspaces: PostizWorkspace[];
defaultWorkspaceId?: string;
maxSelect: number;
onClose: () => void;
onSelect: (items: LibraryMediaItem[]) => void;
}
function resolveUrl(path: string, baseUrl: string): string {
if (path.startsWith("http://") || path.startsWith("https://")) return path;
const origin = baseUrl.replace(/\/api\/.*$/, "");
return `${origin}/${path.replace(/^\//, "")}`;
}
export function MediaLibraryModal({ visible, workspaces, defaultWorkspaceId, maxSelect, onClose, onSelect }: Props) {
const colors = useColors();
const insets = useSafeAreaInsets();
const [activeId, setActiveId] = useState<string>("");
const [items, setItems] = useState<RawMediaItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const activeWorkspace = workspaces.find((w) => w.id === activeId) ?? workspaces[0] ?? null;
useEffect(() => {
if (visible) {
const initial = defaultWorkspaceId ?? workspaces[0]?.id ?? "";
setActiveId(initial);
setSelected(new Set());
}
}, [visible, defaultWorkspaceId, workspaces]);
const load = useCallback(async () => {
if (!activeWorkspace) return;
setLoading(true);
setError(null);
try {
// eslint-disable-next-line no-undef
const res = await globalThis.fetch(`${activeWorkspace.baseUrl}/media`, {
headers: { Authorization: activeWorkspace.apiKey },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const list: RawMediaItem[] = Array.isArray(data)
? data
: (data?.media ?? data?.items ?? data?.files ?? []);
setItems(list);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to load media");
} finally {
setLoading(false);
}
}, [activeWorkspace]);
useEffect(() => {
if (visible && activeWorkspace) {
setSelected(new Set());
load();
}
}, [visible, activeWorkspace, load]);
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) { next.delete(id); }
else if (next.size < maxSelect) { next.add(id); }
return next;
});
};
const handleConfirm = () => {
if (!activeWorkspace) return;
const chosen = items
.filter((i) => selected.has(i.id))
.map((i): LibraryMediaItem => ({ ...i, workspaceId: activeWorkspace.id }));
onSelect(chosen);
};
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
<View style={[styles.root, { backgroundColor: colors.background, paddingTop: insets.top }]}>
{/* Header */}
<View style={[styles.header, { borderBottomColor: colors.border }]}>
<TouchableOpacity onPress={onClose} activeOpacity={0.7} style={styles.closeBtn}>
<Feather name="x" size={20} color={colors.foreground} />
</TouchableOpacity>
<Text style={[styles.title, { color: colors.foreground }]}>Media Library</Text>
<TouchableOpacity
onPress={handleConfirm}
disabled={selected.size === 0}
activeOpacity={0.8}
style={[styles.addBtn, { backgroundColor: selected.size > 0 ? colors.primary : colors.muted }]}
>
<Text style={[styles.addBtnText, { color: colors.primaryForeground }]}>
{selected.size > 0 ? `Add ${selected.size}` : "Add"}
</Text>
</TouchableOpacity>
</View>
{/* Workspace tabs (only shown when >1 workspace) */}
{workspaces.length > 1 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={[styles.tabs, { borderBottomColor: colors.border }]}
contentContainerStyle={styles.tabsContent}
>
{workspaces.map((ws) => {
const active = ws.id === activeId;
return (
<TouchableOpacity
key={ws.id}
onPress={() => setActiveId(ws.id)}
activeOpacity={0.7}
style={[
styles.tab,
active && { borderBottomColor: colors.primary, borderBottomWidth: 2 },
]}
>
<Text style={[styles.tabText, { color: active ? colors.primary : colors.mutedForeground }]}>
{ws.name}
</Text>
</TouchableOpacity>
);
})}
</ScrollView>
)}
{/* Content */}
{loading ? (
<View style={styles.centered}>
<ActivityIndicator color={colors.primary} size="large" />
</View>
) : error ? (
<View style={styles.centered}>
<Feather name="alert-circle" size={28} color={colors.error} />
<Text style={[styles.errorText, { color: colors.mutedForeground }]}>{error}</Text>
<TouchableOpacity onPress={load} style={[styles.retryBtn, { backgroundColor: colors.primary }]} activeOpacity={0.8}>
<Text style={[styles.retryText, { color: colors.primaryForeground }]}>Retry</Text>
</TouchableOpacity>
</View>
) : items.length === 0 ? (
<View style={styles.centered}>
<Feather name="image" size={36} color={colors.mutedForeground} />
<Text style={[styles.emptyText, { color: colors.mutedForeground }]}>No media found</Text>
</View>
) : (
<FlatList
data={items}
keyExtractor={(item) => item.id}
numColumns={3}
contentContainerStyle={[styles.grid, { paddingBottom: insets.bottom + 16 }]}
renderItem={({ item }) => {
const isSelected = selected.has(item.id);
const uri = resolveUrl(item.path, activeWorkspace?.baseUrl ?? "");
return (
<TouchableOpacity onPress={() => toggle(item.id)} activeOpacity={0.8} style={styles.cell}>
<Image source={{ uri }} style={styles.cellImage} contentFit="cover" />
{isSelected && (
<View style={[styles.selectedOverlay, { backgroundColor: colors.primary + "99" }]}>
<View style={[styles.checkCircle, { backgroundColor: colors.primary }]}>
<Feather name="check" size={14} color="#fff" />
</View>
</View>
)}
</TouchableOpacity>
);
}}
/>
)}
</View>
</Modal>
);
}
const CELL = 120;
const styles = StyleSheet.create({
root: { flex: 1 },
header: {
flexDirection: "row", alignItems: "center",
paddingHorizontal: 16, paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth, gap: 12,
},
closeBtn: { padding: 4 },
title: { flex: 1, fontSize: 17, fontFamily: "Inter_600SemiBold" },
addBtn: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: 20 },
addBtnText: { fontSize: 14, fontFamily: "Inter_600SemiBold" },
tabs: { borderBottomWidth: StyleSheet.hairlineWidth, flexGrow: 0 },
tabsContent: { paddingHorizontal: 12, gap: 4 },
tab: { paddingHorizontal: 12, paddingVertical: 12 },
tabText: { fontSize: 13, fontFamily: "Inter_500Medium" },
centered: { flex: 1, alignItems: "center", justifyContent: "center", gap: 12 },
errorText: { fontSize: 14, fontFamily: "Inter_400Regular", textAlign: "center", paddingHorizontal: 32 },
emptyText: { fontSize: 14, fontFamily: "Inter_400Regular" },
retryBtn: { paddingHorizontal: 20, paddingVertical: 10, borderRadius: 10 },
retryText: { fontSize: 14, fontFamily: "Inter_600SemiBold" },
grid: { padding: 2 },
cell: { width: CELL, height: CELL, margin: 2 },
cellImage: { width: CELL, height: CELL, borderRadius: 4 },
selectedOverlay: {
...StyleSheet.absoluteFillObject, borderRadius: 4,
alignItems: "center", justifyContent: "center",
},
checkCircle: { width: 28, height: 28, borderRadius: 14, alignItems: "center", justifyContent: "center" },
});
@@ -5,14 +5,22 @@ import React, {
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
const API_KEY_STORAGE = "postiz_api_key";
const BASE_URL_STORAGE = "postiz_base_url";
const WORKSPACES_KEY = "postiz_workspaces_v2";
const LEGACY_API_KEY = "postiz_api_key";
const LEGACY_BASE_URL = "postiz_base_url";
export const DEFAULT_BASE_URL = "https://postiz.gyozamancave.fr/api/public/v1";
export interface PostizWorkspace {
id: string;
name: string;
apiKey: string;
baseUrl: string;
}
export interface PostizIntegration {
id: string;
name: string;
@@ -45,129 +53,129 @@ export interface PostizUploadResult {
}
interface PostizContextValue {
apiKey: string;
baseUrl: string;
workspaces: PostizWorkspace[];
isConfigured: boolean;
isLoading: boolean;
unauthorized: boolean;
clearUnauthorized: () => void;
clients: Record<string, AxiosInstance>;
addWorkspace: (ws: Omit<PostizWorkspace, "id">) => Promise<void>;
updateWorkspace: (ws: PostizWorkspace) => Promise<void>;
removeWorkspace: (id: string) => Promise<void>;
// backward compat for posts.tsx (first workspace)
client: AxiosInstance | null;
saveSettings: (apiKey: string, baseUrl: string) => Promise<void>;
clearSettings: () => Promise<void>;
apiKey: string;
baseUrl: string;
}
const PostizContext = createContext<PostizContextValue>({
apiKey: "",
baseUrl: DEFAULT_BASE_URL,
workspaces: [],
isConfigured: false,
isLoading: true,
unauthorized: false,
clearUnauthorized: () => {},
clients: {},
addWorkspace: async () => {},
updateWorkspace: async () => {},
removeWorkspace: async () => {},
client: null,
saveSettings: async () => {},
clearSettings: async () => {},
apiKey: "",
baseUrl: DEFAULT_BASE_URL,
});
function createClient(
apiKey: string,
baseUrl: string,
onUnauthorized?: () => void
): AxiosInstance {
const normalizedUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
function makeClient(ws: PostizWorkspace): AxiosInstance {
const baseURL = ws.baseUrl.endsWith("/") ? ws.baseUrl : ws.baseUrl + "/";
const instance = axios.create({
baseURL: normalizedUrl,
headers: {
Authorization: apiKey,
"Content-Type": "application/json",
},
baseURL,
headers: { Authorization: ws.apiKey, "Content-Type": "application/json" },
timeout: 15000,
});
instance.interceptors.request.use((config) => {
console.log(">>> REQUEST:", config.method?.toUpperCase(), (config.baseURL || "") + (config.url || ""));
console.log(">>> REQUEST:", config.method?.toUpperCase(), (config.baseURL ?? "") + (config.url ?? ""));
return config;
});
instance.interceptors.response.use(
(res) => res,
(err) => {
if (axios.isAxiosError(err) && err.response?.status === 401) {
onUnauthorized?.();
}
return Promise.reject(err);
}
);
return instance;
}
function buildClients(list: PostizWorkspace[]): Record<string, AxiosInstance> {
return Object.fromEntries(list.map((ws) => [ws.id, makeClient(ws)]));
}
export function PostizProvider({ children }: { children: React.ReactNode }) {
const [apiKey, setApiKey] = useState("");
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
const [workspaces, setWorkspaces] = useState<PostizWorkspace[]>([]);
const [clients, setClients] = useState<Record<string, AxiosInstance>>({});
const [isLoading, setIsLoading] = useState(true);
const [client, setClient] = useState<AxiosInstance | null>(null);
const [unauthorized, setUnauthorized] = useState(false);
const unauthorizedFiredRef = useRef(false);
const handleUnauthorized = useCallback(() => {
if (unauthorizedFiredRef.current) return;
unauthorizedFiredRef.current = true;
setUnauthorized(true);
}, []);
const clearUnauthorized = useCallback(() => {
unauthorizedFiredRef.current = false;
setUnauthorized(false);
}, []);
useEffect(() => {
(async () => {
try {
const storedKey = await SecureStore.getItemAsync(API_KEY_STORAGE);
const storedUrl = await SecureStore.getItemAsync(BASE_URL_STORAGE);
if (storedKey) {
const url = (storedUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
setApiKey(storedKey);
setBaseUrl(url);
setClient(() => createClient(storedKey, url, handleUnauthorized));
const stored = await SecureStore.getItemAsync(WORKSPACES_KEY);
if (stored) {
const list: PostizWorkspace[] = JSON.parse(stored);
setWorkspaces(list);
setClients(buildClients(list));
} else {
// Migrate legacy single-workspace config
const legacyKey = await SecureStore.getItemAsync(LEGACY_API_KEY);
const legacyUrl = await SecureStore.getItemAsync(LEGACY_BASE_URL);
if (legacyKey) {
const migrated: PostizWorkspace = {
id: Date.now().toString(),
name: "Default",
apiKey: legacyKey,
baseUrl: (legacyUrl || DEFAULT_BASE_URL).replace(/\/$/, ""),
};
const list = [migrated];
await SecureStore.setItemAsync(WORKSPACES_KEY, JSON.stringify(list));
setWorkspaces(list);
setClients(buildClients(list));
}
}
} catch {
} finally {
setIsLoading(false);
}
})();
}, [handleUnauthorized]);
}, []);
const saveSettings = useCallback(
async (newApiKey: string, newBaseUrl: string) => {
await SecureStore.setItemAsync(API_KEY_STORAGE, newApiKey);
await SecureStore.setItemAsync(BASE_URL_STORAGE, newBaseUrl);
setApiKey(newApiKey);
setBaseUrl(newBaseUrl);
clearUnauthorized();
setClient(() => createClient(newApiKey, newBaseUrl, handleUnauthorized));
const persist = useCallback(async (list: PostizWorkspace[]) => {
await SecureStore.setItemAsync(WORKSPACES_KEY, JSON.stringify(list));
setWorkspaces(list);
setClients(buildClients(list));
}, []);
const addWorkspace = useCallback(
async (ws: Omit<PostizWorkspace, "id">) => {
await persist([...workspaces, { ...ws, id: Date.now().toString() }]);
},
[handleUnauthorized, clearUnauthorized]
[workspaces, persist]
);
const clearSettings = useCallback(async () => {
await SecureStore.deleteItemAsync(API_KEY_STORAGE);
await SecureStore.deleteItemAsync(BASE_URL_STORAGE);
setApiKey("");
setBaseUrl(DEFAULT_BASE_URL);
setClient(null);
clearUnauthorized();
}, [clearUnauthorized]);
const updateWorkspace = useCallback(
async (ws: PostizWorkspace) => {
await persist(workspaces.map((w) => (w.id === ws.id ? ws : w)));
},
[workspaces, persist]
);
const removeWorkspace = useCallback(
async (id: string) => {
await persist(workspaces.filter((w) => w.id !== id));
},
[workspaces, persist]
);
const primaryWorkspace = workspaces[0] ?? null;
return (
<PostizContext.Provider
value={{
apiKey,
baseUrl,
isConfigured: !!apiKey,
workspaces,
isConfigured: workspaces.length > 0,
isLoading,
unauthorized,
clearUnauthorized,
client,
saveSettings,
clearSettings,
clients,
addWorkspace,
updateWorkspace,
removeWorkspace,
client: primaryWorkspace ? (clients[primaryWorkspace.id] ?? null) : null,
apiKey: primaryWorkspace?.apiKey ?? "",
baseUrl: primaryWorkspace?.baseUrl ?? DEFAULT_BASE_URL,
}}
>
{children}
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from "react";
import { Platform } from "react-native";
import { usePostiz } from "@/context/PostizContext";
import { PostizPost } from "@/context/PostizContext";
import { stripHtml } from "@/lib/stripHtml";
const POLL_INTERVAL_MS = 15 * 60 * 1000;
const SEEN_KEY = "postiz_seen_statuses";
@@ -44,10 +45,7 @@ async function sendStatusNotification(post: PostizPost) {
await Notifications.scheduleNotificationAsync({
content: {
title: isError ? "Post failed to publish" : "Post published!",
body:
post.content.length > 80
? post.content.slice(0, 80) + "…"
: post.content,
body: (() => { const t = stripHtml(post.content); return t.length > 80 ? t.slice(0, 80) + "…" : t; })(),
data: { postId: post.id },
},
trigger: null,
+12 -7
View File
@@ -1,14 +1,19 @@
export function stripHtml(html: string): string {
return html
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n")
.replace(/<[^>]+>/g, "")
// Decode entities first so encoded tags like &lt;p&gt; are also stripped
let s = html
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ")
.replace(/\n{3,}/g, "\n\n")
.trim();
.replace(/&nbsp;/g, " ");
// Block-level tags → newlines
s = s
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n")
.replace(/<\/div>/gi, "\n")
.replace(/<\/li>/gi, "\n");
// Strip all remaining tags
s = s.replace(/<[^>]+>/g, "");
return s.replace(/\n{3,}/g, "\n\n").trim();
}