7 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
billisdead 365f44dbe4 feat: official Postiz icon + strip HTML from post content display
Release APK / build (push) Has been cancelled
- Replace icon.png with official Postiz logo (1024x1024, generated from
  upstream postiz.svg at gitroomhq/postiz-app)
- Add lib/stripHtml.ts: converts <br>/<p> to newlines, strips all tags,
  decodes HTML entities
- PostCard: use stripHtml on content before truncation and display
- posts.tsx: use stripHtml for context menu preview and clipboard copy
  (API payloads keep original HTML for retry/reschedule)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 16:08:43 +02:00
billisdead 40c2ce20f3 feat: resize images to max 1920px before upload
Release APK / build (push) Has been cancelled
Add expo-image-manipulator. In pickImage(), detect if image dimensions
exceed 1920px and resize (keeping aspect ratio) + compress to JPEG 0.85.
Previously only JPEG quality was set but dimensions were untouched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 15:58:12 +02:00
billisdead aa516667cd fix(ci): skip on Gitea + add contents:write for release creation
Release APK / build (push) Has been cancelled
- Add job condition `github.server_url == 'https://github.com'` so Gitea
  (no runner) ignores the workflow entirely
- Add `permissions: contents: write` required by softprops/action-gh-release

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 13:36:13 +02:00
11 changed files with 1218 additions and 891 deletions
+6 -3
View File
@@ -7,8 +7,11 @@ on:
jobs: jobs:
build: build:
if: github.server_url == 'https://github.com'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60 timeout-minutes: 60
permissions:
contents: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -103,11 +106,11 @@ jobs:
body: | body: |
## Postiz Mobile ${{ github.ref_name }} ## Postiz Mobile ${{ github.ref_name }}
APK signé pour Android — installation directe (sideload). Signed APK for Android — direct install (sideload).
### Installation ### Installation
1. Activer "Sources inconnues" sur l'appareil 1. Enable "Unknown sources" on the device
2. Transférer l'APK et ouvrir pour installer 2. Transfer the APK to the device and open it to install
files: ${{ steps.apk.outputs.path }} files: ${{ steps.apk.outputs.path }}
draft: false draft: false
prerelease: ${{ contains(github.ref_name, '-') }} prerelease: ${{ contains(github.ref_name, '-') }}
+360 -295
View File
@@ -4,10 +4,11 @@ import DateTimePicker from "@react-native-community/datetimepicker";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
import { Image } from "expo-image"; import { Image } from "expo-image";
import * as ImageManipulator from "expo-image-manipulator";
import * as ImagePicker from "expo-image-picker"; import * as ImagePicker from "expo-image-picker";
import { fetch as expoFetch } from "expo/fetch"; import { fetch as expoFetch } from "expo/fetch";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import React, { useEffect, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
@@ -23,9 +24,17 @@ import {
import { KeyboardAwareScrollView } from "react-native-keyboard-controller"; import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ChannelChip } from "@/components/ChannelChip"; 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"; import { useColors } from "@/hooks/useColors";
const DRAFT_STORAGE_KEY = "postiz_local_draft"; const DRAFT_STORAGE_KEY = "postiz_local_draft";
const MAX_IMAGES = 4;
const NETWORK_CHAR_LIMITS: Record<string, number> = { const NETWORK_CHAR_LIMITS: Record<string, number> = {
twitter: 280, x: 280, twitter: 280, x: 280,
@@ -36,10 +45,44 @@ const NETWORK_CHAR_LIMITS: Record<string, number> = {
tiktok: 2200, 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() { export default function ComposeScreen() {
const colors = useColors(); const colors = useColors();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { client, isConfigured, apiKey, baseUrl } = usePostiz(); const { workspaces, clients, isConfigured } = usePostiz();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId } = const { prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId } =
useLocalSearchParams<{ useLocalSearchParams<{
@@ -48,6 +91,7 @@ export default function ComposeScreen() {
prefillImagePath?: string; prefillImagePath?: string;
prefillImageId?: string; prefillImageId?: string;
}>(); }>();
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [selectedChannels, setSelectedChannels] = useState<string[]>([]); const [selectedChannels, setSelectedChannels] = useState<string[]>([]);
const [postNow, setPostNow] = useState(false); const [postNow, setPostNow] = useState(false);
@@ -56,24 +100,23 @@ export default function ComposeScreen() {
); );
const [showDatePicker, setShowDatePicker] = useState(false); const [showDatePicker, setShowDatePicker] = useState(false);
const [showTimePicker, setShowTimePicker] = useState(false); const [showTimePicker, setShowTimePicker] = useState(false);
const [imageUri, setImageUri] = useState<string | null>(null); const [mediaItems, setMediaItems] = useState<MediaItem[]>([]);
const [existingMedia, setExistingMedia] = useState<Array<{ id: string; path: string }>>([]); const [showMediaLibrary, setShowMediaLibrary] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [draftBanner, setDraftBanner] = useState(false); const [draftBanner, setDraftBanner] = useState(false);
useEffect(() => { useEffect(() => {
if (prefillContent) { if (prefillContent) setContent(String(prefillContent));
setContent(String(prefillContent));
}
if (prefillIntegrationIds) { if (prefillIntegrationIds) {
setSelectedChannels(String(prefillIntegrationIds).split(",").filter(Boolean)); setSelectedChannels(String(prefillIntegrationIds).split(",").filter(Boolean));
} }
if (prefillImagePath && prefillImageId) { if (prefillImagePath && prefillImageId) {
setExistingMedia([{ id: String(prefillImageId), path: String(prefillImagePath) }]); // Prefilled image has unknown workspace; associate with first workspace
setImageUri(String(prefillImagePath)); 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(() => { useEffect(() => {
if (prefillContent) return; if (prefillContent) return;
@@ -86,21 +129,61 @@ export default function ComposeScreen() {
}); });
}, [prefillContent]); }, [prefillContent]);
const { data: integrations, isLoading: loadingIntegrations } = // Fetch integrations from ALL workspaces in parallel
useQuery<PostizIntegration[]>({ const { data: allIntegrations, isLoading: loadingIntegrations } =
queryKey: ["integrations", !!client], useQuery<IntegrationWithWorkspace[]>({
queryKey: ["integrations-all", workspaces.map((w) => w.id).join(",")],
queryFn: async () => { queryFn: async () => {
const results = await Promise.all(
workspaces.map(async (ws) => {
const client = clients[ws.id];
if (!client) return []; if (!client) return [];
const res = await client.get("integrations"); 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, staleTime: 60000,
}); });
const effectiveCharLimit = (() => { // Group: workspace → network label → integrations
if (selectedChannels.length === 0 || !integrations) return 3000; const grouped = useMemo(() => {
const selected = integrations.filter((i) => selectedChannels.includes(i.id)); 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 limits = selected.map((i) => {
const t = (i.type ?? i.internalType ?? "").toLowerCase(); const t = (i.type ?? i.internalType ?? "").toLowerCase();
for (const [key, limit] of Object.entries(NETWORK_CHAR_LIMITS)) { for (const [key, limit] of Object.entries(NETWORK_CHAR_LIMITS)) {
@@ -109,7 +192,7 @@ export default function ComposeScreen() {
return 3000; return 3000;
}); });
return Math.min(...limits); return Math.min(...limits);
})(); }, [selectedChannels, allIntegrations]);
const saveDraft = async () => { const saveDraft = async () => {
const draft = { content, integrationIds: selectedChannels }; const draft = { content, integrationIds: selectedChannels };
@@ -129,8 +212,6 @@ export default function ComposeScreen() {
} catch {} } catch {}
}; };
const dismissDraft = () => setDraftBanner(false);
const toggleChannel = (id: string) => { const toggleChannel = (id: string) => {
setSelectedChannels((prev) => setSelectedChannels((prev) =>
prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id] prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]
@@ -138,56 +219,84 @@ export default function ComposeScreen() {
}; };
const pickImage = async () => { 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(); const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== "granted") { if (status !== "granted") {
Alert.alert("Permission required", "Allow access to your photo library."); Alert.alert("Permission required", "Allow access to your photo library.");
return; return;
} }
const remaining = MAX_IMAGES - mediaItems.length;
const result = await ImagePicker.launchImageLibraryAsync({ const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"], mediaTypes: ["images"],
allowsMultipleSelection: true,
selectionLimit: remaining,
allowsEditing: false, allowsEditing: false,
quality: 0.85, quality: 1,
}); });
if (!result.canceled && result.assets[0]) { if (!result.canceled && result.assets.length > 0) {
setImageUri(result.assets[0].uri); const MAX_DIM = 1920;
setExistingMedia([]); const processed: string[] = [];
for (const asset of result.assets) {
const w = asset.width ?? 0;
const h = asset.height ?? 0;
if (w > MAX_DIM || h > MAX_DIM) {
const landscape = w >= h;
const resized = await ImageManipulator.manipulateAsync(
asset.uri,
[{ resize: landscape ? { width: MAX_DIM } : { height: MAX_DIM } }],
{ compress: 0.85, format: ImageManipulator.SaveFormat.JPEG }
);
processed.push(resized.uri);
} else {
processed.push(asset.uri);
}
}
setMediaItems((prev) =>
[...prev, ...processed.map((uri): MediaItem => ({ type: "local", uri }))].slice(0, MAX_IMAGES)
);
} }
}; };
const removeImage = () => { const removeMediaItem = (index: number) => {
setImageUri(null); setMediaItems((prev) => prev.filter((_, i) => i !== index));
setExistingMedia([]);
}; };
const uploadImage = async (): Promise<PostizUploadResult> => { // Upload local images to a specific workspace, returns { id, path }[]
setUploading(true); const uploadLocalToWorkspace = async (
try { 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(); const formData = new FormData();
if (Platform.OS === "web") { if (Platform.OS === "web") {
const response = await expoFetch(imageUri!); const response = await expoFetch(uri);
const blob = await response.blob(); const blob = await response.blob();
formData.append("file", blob, "upload.jpg"); formData.append("file", blob, "upload.jpg");
} else { } else {
formData.append("file", { formData.append("file", {
uri: imageUri!, uri,
name: "upload.jpg", name: "upload.jpg",
type: "image/jpeg", type: "image/jpeg",
} as unknown as Blob); } as unknown as Blob);
} }
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
const uploadRes = await globalThis.fetch(`${baseUrl}/upload`, { const uploadRes = await globalThis.fetch(`${ws.baseUrl}/upload`, {
method: "POST", method: "POST",
headers: { Authorization: apiKey }, headers: { Authorization: ws.apiKey },
body: formData, body: formData,
}); });
if (!uploadRes.ok) { if (!uploadRes.ok) {
const raw = await uploadRes.text().catch(() => uploadRes.statusText); 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; const uploaded = (await uploadRes.json()) as PostizUploadResult;
} finally { result.push({ id: uploaded.id, path: uploaded.path });
setUploading(false);
} }
return result;
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -202,37 +311,55 @@ export default function ComposeScreen() {
} }
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
setSubmitting(true); setSubmitting(true);
try { try {
let media: Array<{ id: string; path: string }> = []; // Group selected channels by workspace
const isLocalFile = imageUri && !imageUri.startsWith("http"); const byWorkspace = new Map<string, { ws: PostizWorkspace; channelIds: string[] }>();
if (imageUri && isLocalFile) { for (const channelId of selectedChannels) {
const uploaded = await uploadImage(); const intg = allIntegrations?.find((i) => i.id === channelId);
media = [{ id: uploaded.id, path: uploaded.path }]; if (!intg) continue;
} else if (existingMedia.length > 0) { if (!byWorkspace.has(intg.workspaceId)) {
media = existingMedia; 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 = { const payload = {
type: postNow ? "now" : "schedule", type: postNow ? "now" : "schedule",
date: postNow ? new Date().toISOString() : scheduleDate.toISOString(), date: postNow ? new Date().toISOString() : scheduleDate.toISOString(),
shortLink: false, shortLink: false,
tags: [] as string[], tags: [] as string[],
posts: selectedChannels.map((integrationId) => { posts: channelIds.map((integrationId) => ({
return {
integration: { id: integrationId }, integration: { id: integrationId },
value: [{ content: content.trim(), image: media }], value: [{ content: content.trim(), image: media }],
})),
}; };
}),
};
const body = JSON.stringify(payload); 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 // eslint-disable-next-line no-undef
const res = await globalThis.fetch(`${baseUrl}/posts`, { const res = await globalThis.fetch(`${ws.baseUrl}/posts`, {
method: "POST", method: "POST",
headers: { headers: { Authorization: ws.apiKey, "Content-Type": "application/json" },
Authorization: apiKey,
"Content-Type": "application/json",
},
body, body,
}); });
@@ -240,14 +367,15 @@ export default function ComposeScreen() {
let detail = ""; let detail = "";
try { try {
const raw = await res.text(); const raw = await res.text();
console.log("[compose] 400 body:", raw); console.log(`[compose][${ws.name}] error body:`, raw);
detail = raw.slice(0, 500); detail = raw.slice(0, 500);
} catch { } catch { detail = res.statusText; }
detail = res.statusText; throw new Error(`[${ws.name}] HTTP ${res.status}: ${detail}`);
}
throw new Error(`HTTP ${res.status}: ${detail}`);
} }
})
);
if (hasLocalImages) setUploading(false);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
await AsyncStorage.removeItem(DRAFT_STORAGE_KEY); await AsyncStorage.removeItem(DRAFT_STORAGE_KEY);
Alert.alert( Alert.alert(
@@ -258,6 +386,7 @@ export default function ComposeScreen() {
queryClient.invalidateQueries({ queryKey: ["posts"] }); queryClient.invalidateQueries({ queryKey: ["posts"] });
queryClient.invalidateQueries({ queryKey: ["posts-list"] }); queryClient.invalidateQueries({ queryKey: ["posts-list"] });
} catch (e: unknown) { } catch (e: unknown) {
setUploading(false);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
const msg = e instanceof Error ? e.message : "Could not submit post."; const msg = e instanceof Error ? e.message : "Could not submit post.";
Alert.alert("Failed", msg); Alert.alert("Failed", msg);
@@ -270,18 +399,13 @@ export default function ComposeScreen() {
setContent(""); setContent("");
setSelectedChannels([]); setSelectedChannels([]);
setPostNow(false); setPostNow(false);
setImageUri(null); setMediaItems([]);
setExistingMedia([]);
setDraftBanner(false); setDraftBanner(false);
setScheduleDate(new Date(Date.now() + 60 * 60 * 1000)); setScheduleDate(new Date(Date.now() + 60 * 60 * 1000));
}; };
const formatDateLabel = (d: Date) => const formatDateLabel = (d: Date) =>
d.toLocaleDateString("en-US", { d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
month: "short",
day: "numeric",
year: "numeric",
});
const formatTimeLabel = (d: Date) => const formatTimeLabel = (d: Date) =>
d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" }); d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
@@ -290,17 +414,14 @@ export default function ComposeScreen() {
return ( return (
<View style={[styles.centered, { backgroundColor: colors.background }]}> <View style={[styles.centered, { backgroundColor: colors.background }]}>
<Feather name="lock" size={32} color={colors.mutedForeground} /> <Feather name="lock" size={32} color={colors.mutedForeground} />
<Text style={[styles.sectionTitle, { color: colors.foreground }]}> <Text style={[styles.sectionTitle, { color: colors.foreground }]}>Not Configured</Text>
Not Configured <Text style={[styles.hint, { color: colors.mutedForeground }]}>Add a workspace in Settings</Text>
</Text>
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
Add your API key in Settings
</Text>
</View> </View>
); );
} }
return ( return (
<>
<KeyboardAwareScrollView <KeyboardAwareScrollView
style={{ flex: 1, backgroundColor: colors.background }} style={{ flex: 1, backgroundColor: colors.background }}
contentContainerStyle={[ contentContainerStyle={[
@@ -317,24 +438,17 @@ export default function ComposeScreen() {
{draftBanner && ( {draftBanner && (
<View style={[styles.draftBanner, { backgroundColor: colors.card, borderColor: colors.border }]}> <View style={[styles.draftBanner, { backgroundColor: colors.card, borderColor: colors.border }]}>
<Feather name="file-text" size={14} color={colors.primary} /> <Feather name="file-text" size={14} color={colors.primary} />
<Text style={[styles.draftBannerText, { color: colors.foreground }]}> <Text style={[styles.draftBannerText, { color: colors.foreground }]}>You have a saved draft</Text>
You have a saved draft
</Text>
<TouchableOpacity onPress={restoreDraft} activeOpacity={0.7}> <TouchableOpacity onPress={restoreDraft} activeOpacity={0.7}>
<Text style={[styles.draftBannerAction, { color: colors.primary }]}>Restore</Text> <Text style={[styles.draftBannerAction, { color: colors.primary }]}>Restore</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity onPress={dismissDraft} activeOpacity={0.7}> <TouchableOpacity onPress={() => setDraftBanner(false)} activeOpacity={0.7}>
<Feather name="x" size={14} color={colors.mutedForeground} /> <Feather name="x" size={14} color={colors.mutedForeground} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)} )}
<View <View style={[styles.textArea, { backgroundColor: colors.card, borderColor: colors.border }]}>
style={[
styles.textArea,
{ backgroundColor: colors.card, borderColor: colors.border },
]}
>
<TextInput <TextInput
style={[styles.textInput, { color: colors.foreground }]} style={[styles.textInput, { color: colors.foreground }]}
placeholder="What do you want to post?" placeholder="What do you want to post?"
@@ -369,53 +483,106 @@ export default function ComposeScreen() {
</View> </View>
</View> </View>
{imageUri && ( {mediaItems.length > 0 && (
<View style={styles.imagePreviewWrap}> <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 <Image
source={{ uri: imageUri }} source={{ uri }}
style={[styles.imagePreview, { borderColor: colors.border }]} style={[styles.imageThumb, { borderColor: colors.border }]}
contentFit="cover" contentFit="cover"
/> />
<TouchableOpacity <TouchableOpacity
onPress={removeImage} onPress={() => removeMediaItem(idx)}
style={[styles.removeImg, { backgroundColor: colors.destructive }]} style={[styles.removeImg, { backgroundColor: colors.destructive }]}
> >
<Feather name="x" size={12} color="#fff" /> <Feather name="x" size={12} color="#fff" />
</TouchableOpacity> </TouchableOpacity>
{item.type === "uploaded" && (
<View style={[styles.uploadedBadge, { backgroundColor: colors.success }]}>
<Feather name="cloud" size={8} color="#fff" />
</View> </View>
)} )}
</View>
);
})}
</ScrollView>
)}
{mediaItems.length < MAX_IMAGES && (
<View style={styles.mediaBtnsRow}>
<TouchableOpacity <TouchableOpacity
onPress={pickImage} onPress={pickImage}
activeOpacity={0.7} activeOpacity={0.7}
style={[ style={[styles.mediaBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
styles.mediaBtn,
{ backgroundColor: colors.card, borderColor: colors.border },
]}
> >
<Feather name="image" size={16} color={colors.mutedForeground} /> <Feather name="image" size={16} color={colors.mutedForeground} />
<Text style={[styles.mediaBtnText, { color: colors.mutedForeground }]}> <Text style={[styles.mediaBtnText, { color: colors.mutedForeground }]}>
{imageUri ? "Change image" : "Add image"} {mediaItems.length === 0 ? "Add image" : "Add more"}
</Text> </Text>
</TouchableOpacity> </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 grouped by workspace then network type */}
CHANNELS <Text style={[styles.sectionLabel, { color: colors.mutedForeground }]}>CHANNELS</Text>
</Text>
{loadingIntegrations ? ( {loadingIntegrations ? (
<ActivityIndicator color={colors.primary} style={{ marginVertical: 8 }} /> <ActivityIndicator color={colors.primary} style={{ marginVertical: 8 }} />
) : (integrations ?? []).length === 0 ? ( ) : grouped.length === 0 ? (
<Text style={[styles.hint, { color: colors.mutedForeground }]}> <Text style={[styles.hint, { color: colors.mutedForeground }]}>
No channels found. Add integrations in your Postiz instance. No channels found. Add integrations in your Postiz instance.
</Text> </Text>
) : ( ) : (
<ScrollView <View style={styles.channelGroups}>
horizontal {grouped.map(({ workspace, networks }, wsIdx) => (
showsHorizontalScrollIndicator={false} <View
contentContainerStyle={styles.channelList} 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 <ChannelChip
key={intg.id} key={intg.id}
integration={intg} integration={intg}
@@ -423,20 +590,19 @@ export default function ComposeScreen() {
onToggle={() => toggleChannel(intg.id)} onToggle={() => toggleChannel(intg.id)}
/> />
))} ))}
</ScrollView> </View>
</View>
))}
</View>
</View>
))}
</View>
)} )}
<View <View style={[styles.scheduleRow, { backgroundColor: colors.card, borderColor: colors.border }]}>
style={[
styles.scheduleRow,
{ backgroundColor: colors.card, borderColor: colors.border },
]}
>
<View style={styles.scheduleRowLeft}> <View style={styles.scheduleRowLeft}>
<Feather name="zap" size={16} color={colors.primary} /> <Feather name="zap" size={16} color={colors.primary} />
<Text style={[styles.scheduleLabel, { color: colors.foreground }]}> <Text style={[styles.scheduleLabel, { color: colors.foreground }]}>Post now</Text>
Post now
</Text>
</View> </View>
<Switch <Switch
value={postNow} value={postNow}
@@ -449,14 +615,8 @@ export default function ComposeScreen() {
{!postNow && ( {!postNow && (
<View style={styles.dateTimeRow}> <View style={styles.dateTimeRow}>
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => { setShowTimePicker(false); setShowDatePicker((v) => !v); }}
setShowTimePicker(false); style={[styles.dateBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
setShowDatePicker((v) => !v);
}}
style={[
styles.dateBtn,
{ backgroundColor: colors.card, borderColor: colors.border },
]}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Feather name="calendar" size={14} color={colors.primary} /> <Feather name="calendar" size={14} color={colors.primary} />
@@ -465,14 +625,8 @@ export default function ComposeScreen() {
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => { setShowDatePicker(false); setShowTimePicker((v) => !v); }}
setShowDatePicker(false); style={[styles.dateBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
setShowTimePicker((v) => !v);
}}
style={[
styles.dateBtn,
{ backgroundColor: colors.card, borderColor: colors.border },
]}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Feather name="clock" size={14} color={colors.primary} /> <Feather name="clock" size={14} color={colors.primary} />
@@ -536,21 +690,14 @@ export default function ComposeScreen() {
disabled={submitting || uploading} disabled={submitting || uploading}
style={[ style={[
styles.submitBtn, styles.submitBtn,
{ { backgroundColor: submitting || uploading ? colors.muted : colors.primary },
backgroundColor:
submitting || uploading ? colors.muted : colors.primary,
},
]} ]}
> >
{submitting || uploading ? ( {submitting || uploading ? (
<ActivityIndicator color={colors.primaryForeground} size="small" /> <ActivityIndicator color={colors.primaryForeground} size="small" />
) : ( ) : (
<> <>
<Feather <Feather name={postNow ? "send" : "clock"} size={16} color={colors.primaryForeground} />
name={postNow ? "send" : "clock"}
size={16}
color={colors.primaryForeground}
/>
<Text style={[styles.submitText, { color: colors.primaryForeground }]}> <Text style={[styles.submitText, { color: colors.primaryForeground }]}>
{postNow ? "Publish Now" : "Schedule Post"} {postNow ? "Publish Now" : "Schedule Post"}
</Text> </Text>
@@ -558,179 +705,97 @@ export default function ComposeScreen() {
)} )}
</TouchableOpacity> </TouchableOpacity>
</KeyboardAwareScrollView> </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({ const styles = StyleSheet.create({
container: { container: { paddingHorizontal: 16, gap: 14 },
paddingHorizontal: 16, centered: { flex: 1, alignItems: "center", justifyContent: "center", gap: 10 },
gap: 14, textArea: { borderRadius: 14, borderWidth: 1, padding: 14, minHeight: 140 },
}, textInput: { fontSize: 15, fontFamily: "Inter_400Regular", lineHeight: 22, minHeight: 100 },
centered: { charCountRow: { flexDirection: "row", justifyContent: "flex-end", alignItems: "center", gap: 6, marginTop: 4 },
flex: 1, charCountLabel: { fontSize: 10, fontFamily: "Inter_400Regular" },
alignItems: "center", charCount: { fontSize: 11, fontFamily: "Inter_400Regular" },
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: { draftBanner: {
flexDirection: "row", flexDirection: "row", alignItems: "center", gap: 8,
alignItems: "center", paddingHorizontal: 14, paddingVertical: 10, borderRadius: 12, borderWidth: 1,
gap: 8,
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 12,
borderWidth: 1,
},
draftBannerText: {
flex: 1,
fontSize: 13,
fontFamily: "Inter_400Regular",
},
draftBannerAction: {
fontSize: 13,
fontFamily: "Inter_600SemiBold",
}, },
draftBannerText: { flex: 1, fontSize: 13, fontFamily: "Inter_400Regular" },
draftBannerAction: { fontSize: 13, fontFamily: "Inter_600SemiBold" },
draftBtn: { draftBtn: {
flexDirection: "row", flexDirection: "row", alignItems: "center", justifyContent: "center",
alignItems: "center", gap: 6, paddingVertical: 10, borderRadius: 12, borderWidth: 1,
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,
}, },
draftBtnText: { fontSize: 13, fontFamily: "Inter_500Medium" },
imageRow: { gap: 10, paddingRight: 4 },
imageThumbWrap: { position: "relative" },
imageThumb: { width: 100, height: 100, borderRadius: 10, borderWidth: 1 },
removeImg: { removeImg: {
position: "absolute", position: "absolute", top: 4, right: 4,
top: 4, width: 20, height: 20, borderRadius: 10, alignItems: "center", justifyContent: "center",
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: { mediaBtn: {
flexDirection: "row", flexDirection: "row", alignItems: "center", gap: 8,
alignItems: "center", paddingHorizontal: 14, paddingVertical: 10, borderRadius: 10, borderWidth: 1,
gap: 8,
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 10,
borderWidth: 1,
alignSelf: "flex-start",
}, },
mediaBtnText: { mediaBtnText: { fontSize: 13, fontFamily: "Inter_500Medium" },
fontSize: 13, sectionLabel: { fontSize: 11, fontFamily: "Inter_600SemiBold", letterSpacing: 0.8, marginBottom: -6 },
fontFamily: "Inter_500Medium", sectionTitle: { fontSize: 18, fontFamily: "Inter_600SemiBold" },
}, hint: { fontSize: 13, fontFamily: "Inter_400Regular", textAlign: "center" },
sectionLabel: { channelGroups: { gap: 0 },
fontSize: 11, workspaceSection: { borderRadius: 14, borderWidth: 1, overflow: "hidden" },
fontFamily: "Inter_600SemiBold", workspaceHeader: {
letterSpacing: 0.8, flexDirection: "row", alignItems: "center", gap: 6,
marginBottom: -6, paddingHorizontal: 12, paddingVertical: 8,
}, borderBottomWidth: StyleSheet.hairlineWidth,
sectionTitle: {
fontSize: 18,
fontFamily: "Inter_600SemiBold",
},
hint: {
fontSize: 13,
fontFamily: "Inter_400Regular",
textAlign: "center",
},
channelList: {
flexDirection: "row",
gap: 8,
flexWrap: "wrap",
}, },
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: { scheduleRow: {
flexDirection: "row", flexDirection: "row", alignItems: "center", justifyContent: "space-between",
alignItems: "center", paddingHorizontal: 16, paddingVertical: 12, borderRadius: 12, borderWidth: 1,
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,
}, },
scheduleRowLeft: { flexDirection: "row", alignItems: "center", gap: 10 },
scheduleLabel: { fontSize: 15, fontFamily: "Inter_500Medium" },
dateTimeRow: { flexDirection: "row", gap: 10 },
dateBtn: { dateBtn: {
flex: 1, flex: 1, flexDirection: "row", alignItems: "center", gap: 8,
flexDirection: "row", paddingHorizontal: 12, paddingVertical: 10, borderRadius: 10, borderWidth: 1,
alignItems: "center",
gap: 8,
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 10,
borderWidth: 1,
},
dateBtnText: {
fontSize: 13,
fontFamily: "Inter_500Medium",
}, },
dateBtnText: { fontSize: 13, fontFamily: "Inter_500Medium" },
submitBtn: { submitBtn: {
flexDirection: "row", flexDirection: "row", alignItems: "center", justifyContent: "center",
alignItems: "center", gap: 8, paddingVertical: 14, borderRadius: 14, marginTop: 4,
justifyContent: "center",
gap: 8,
paddingVertical: 14,
borderRadius: 14,
marginTop: 4,
},
submitText: {
fontSize: 15,
fontFamily: "Inter_600SemiBold",
}, },
submitText: { fontSize: 15, fontFamily: "Inter_600SemiBold" },
}); });
+5 -3
View File
@@ -22,6 +22,7 @@ import { PostCard } from "@/components/PostCard";
import { PostizPost, usePostiz } from "@/context/PostizContext"; import { PostizPost, usePostiz } from "@/context/PostizContext";
import { useColors } from "@/hooks/useColors"; import { useColors } from "@/hooks/useColors";
import { extractError } from "@/lib/extractError"; import { extractError } from "@/lib/extractError";
import { stripHtml } from "@/lib/stripHtml";
const SORT_STORAGE_KEY = "postiz_posts_sort"; const SORT_STORAGE_KEY = "postiz_posts_sort";
@@ -154,7 +155,7 @@ export default function PostsScreen() {
router.push({ router.push({
pathname: "/(tabs)/compose", pathname: "/(tabs)/compose",
params: { params: {
prefillContent: post.content, prefillContent: stripHtml(post.content),
prefillIntegrationIds: integrations.map((i) => i.id).join(","), prefillIntegrationIds: integrations.map((i) => i.id).join(","),
}, },
}); });
@@ -193,14 +194,15 @@ export default function PostsScreen() {
const showContextMenu = (post: PostizPost) => { const showContextMenu = (post: PostizPost) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
const preview = post.content.slice(0, 60) + (post.content.length > 60 ? "…" : ""); const plain = stripHtml(post.content);
const preview = plain.slice(0, 60) + (plain.length > 60 ? "…" : "");
const buttons: Array<{ text: string; style?: "cancel" | "destructive" | "default"; onPress?: () => void }> = []; const buttons: Array<{ text: string; style?: "cancel" | "destructive" | "default"; onPress?: () => void }> = [];
buttons.push({ buttons.push({
text: "Copy text", text: "Copy text",
onPress: async () => { onPress: async () => {
await Clipboard.setStringAsync(post.content); await Clipboard.setStringAsync(stripHtml(post.content));
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
setCopyToast(true); setCopyToast(true);
setTimeout(() => setCopyToast(false), 2000); setTimeout(() => setCopyToast(false), 2000);
+211 -220
View File
@@ -1,7 +1,7 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import axios from "axios"; import axios from "axios";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
import React, { useEffect, useState } from "react"; import React, { useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
@@ -15,49 +15,73 @@ import {
} from "react-native"; } from "react-native";
import { KeyboardAwareScrollView } from "react-native-keyboard-controller"; import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context"; 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 { useColors } from "@/hooks/useColors";
import { extractError } from "@/lib/extractError"; 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() { export default function SettingsScreen() {
const colors = useColors(); const colors = useColors();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { apiKey, baseUrl, isConfigured, saveSettings, clearSettings } = usePostiz(); const { workspaces, isConfigured, addWorkspace, updateWorkspace, removeWorkspace } = usePostiz();
const [inputKey, setInputKey] = useState(apiKey); const [form, setForm] = useState<FormState | null>(null);
const [inputUrl, setInputUrl] = useState(baseUrl || DEFAULT_BASE_URL);
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
const [validating, setValidating] = useState(false); const [validating, setValidating] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [validationStatus, setValidationStatus] = useState<"idle" | "ok" | "error">("idle"); const [validationStatus, setValidationStatus] = useState<"idle" | "ok" | "error">("idle");
const [errorDetail, setErrorDetail] = useState<string>(""); const [errorDetail, setErrorDetail] = useState("");
useEffect(() => { const openAdd = () => {
setInputKey(apiKey); setForm(EMPTY_FORM);
setInputUrl(baseUrl || DEFAULT_BASE_URL); setShowKey(false);
}, [apiKey, baseUrl]); 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 () => { 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."); Alert.alert("Missing fields", "Please enter both API key and base URL.");
return; return;
} }
setValidating(true); setValidating(true);
setValidationStatus("idle"); resetValidation();
setErrorDetail(""); const cleanUrl = form.url.trim().replace(/\/$/, "");
const cleanUrl = inputUrl.trim().replace(/\/$/, ""); const variants = [form.key.trim(), `Bearer ${form.key.trim()}`];
let lastError = "";
const authVariants = [ for (const auth of variants) {
inputKey.trim(),
`Bearer ${inputKey.trim()}`,
];
let lastError: string = "";
for (const authHeader of authVariants) {
try { try {
await axios.get(`${cleanUrl}/integrations`, { await axios.get(`${cleanUrl}/integrations`, {
headers: { Authorization: authHeader }, headers: { Authorization: auth },
timeout: 10000, timeout: 10000,
maxRedirects: 0, maxRedirects: 0,
}); });
@@ -67,21 +91,17 @@ export default function SettingsScreen() {
return; return;
} catch (err: unknown) { } catch (err: unknown) {
if (axios.isAxiosError(err)) { if (axios.isAxiosError(err)) {
const status = err.response?.status; const s = err.response?.status;
if (status === 307 || status === 301 || status === 302 || status === 308) { if (s === 307 || s === 301 || s === 302 || s === 308) {
const location = err.response?.headers?.location ?? "unknown"; const loc = 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.`; lastError = `HTTP ${s} redirect → ${loc}. Check the Authorization format or base URL.`;
continue;
}
if (status === 401 || status === 403) {
lastError = `HTTP ${status}: Invalid or expired API key.`;
continue; continue;
} }
if (s === 401 || s === 403) { lastError = `HTTP ${s}: Invalid or expired API key.`; continue; }
} }
lastError = extractError(err); lastError = extractError(err);
} }
} }
setErrorDetail(lastError); setErrorDetail(lastError);
setValidationStatus("error"); setValidationStatus("error");
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
@@ -89,37 +109,38 @@ export default function SettingsScreen() {
}; };
const handleSave = async () => { const handleSave = async () => {
if (!inputKey.trim() || !inputUrl.trim()) { if (!form) return;
Alert.alert("Missing fields", "Please enter both API key and base URL."); if (!form.name.trim()) { Alert.alert("Missing name", "Please enter a name for this workspace."); return; }
return; if (!form.key.trim() || !form.url.trim()) { Alert.alert("Missing fields", "Please enter both API key and base URL."); return; }
}
setSaving(true); setSaving(true);
try { 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); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
Alert.alert("Saved", "Settings saved successfully."); closeForm();
} catch (err: unknown) { } catch (err: unknown) {
Alert.alert("Error", `Failed to save settings.\n${extractError(err)}`); Alert.alert("Error", `Failed to save.\n${extractError(err)}`);
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
const handleClear = () => { const handleDelete = (ws: PostizWorkspace) => {
Alert.alert( Alert.alert(
"Disconnect", "Remove workspace",
"Remove your API key and disconnect from Postiz?", `Remove "${ws.name}"? Channels from this workspace will no longer be available.`,
[ [
{ text: "Cancel", style: "cancel" }, { text: "Cancel", style: "cancel" },
{ {
text: "Disconnect", text: "Remove",
style: "destructive", style: "destructive",
onPress: async () => { onPress: async () => {
await clearSettings(); if (form?.id === ws.id) closeForm();
setInputKey(""); await removeWorkspace(ws.id);
setInputUrl(DEFAULT_BASE_URL);
setValidationStatus("idle");
setErrorDetail("");
}, },
}, },
] ]
@@ -140,34 +161,96 @@ export default function SettingsScreen() {
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{!isConfigured && ( {/* Status banner */}
{!isConfigured ? (
<View style={[styles.banner, { backgroundColor: colors.primary + "18", borderColor: colors.primary + "40" }]}> <View style={[styles.banner, { backgroundColor: colors.primary + "18", borderColor: colors.primary + "40" }]}>
<Feather name="info" size={16} color={colors.primary} /> <Feather name="info" size={16} color={colors.primary} />
<Text style={[styles.bannerText, { 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> </Text>
</View> </View>
)} ) : (
{isConfigured && (
<View style={[styles.connectedBadge, { backgroundColor: colors.success + "18", borderColor: colors.success + "40" }]}> <View style={[styles.connectedBadge, { backgroundColor: colors.success + "18", borderColor: colors.success + "40" }]}>
<Feather name="check-circle" size={14} color={colors.success} /> <Feather name="check-circle" size={14} color={colors.success} />
<Text style={[styles.connectedText, { color: colors.success }]}> <Text style={[styles.connectedText, { color: colors.success }]}>
Connected to Postiz {workspaces.length} workspace{workspaces.length > 1 ? "s" : ""} configured
</Text> </Text>
</View> </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> <Text style={[styles.label, { color: colors.mutedForeground }]}>BASE URL</Text>
<View style={[styles.inputWrap, { backgroundColor: colors.card, borderColor: colors.border }]}> <View style={[styles.inputWrap, { backgroundColor: colors.background, borderColor: colors.border }]}>
<Feather name="globe" size={16} color={colors.mutedForeground} style={styles.inputIcon} /> <Feather name="globe" size={15} color={colors.mutedForeground} />
<TextInput <TextInput
style={[styles.input, { color: colors.foreground }]} style={[styles.input, { color: colors.foreground }]}
placeholder="https://postiz.example.com/api/public/v1" placeholder="https://postiz.example.com/api/public/v1"
placeholderTextColor={colors.mutedForeground} placeholderTextColor={colors.mutedForeground}
value={inputUrl} value={form.url}
onChangeText={(t) => { setInputUrl(t); setValidationStatus("idle"); setErrorDetail(""); }} onChangeText={(t) => patchForm({ url: t })}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
keyboardType="url" keyboardType="url"
@@ -175,31 +258,30 @@ export default function SettingsScreen() {
</View> </View>
</View> </View>
<View style={styles.section}> {/* API Key */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>API KEY</Text> <Text style={[styles.label, { color: colors.mutedForeground }]}>API KEY</Text>
<View style={[styles.inputWrap, { backgroundColor: colors.card, borderColor: colors.border }]}> <View style={[styles.inputWrap, { backgroundColor: colors.background, borderColor: colors.border }]}>
<Feather name="key" size={16} color={colors.mutedForeground} style={styles.inputIcon} /> <Feather name="key" size={15} color={colors.mutedForeground} />
<TextInput <TextInput
style={[styles.input, { color: colors.foreground }]} style={[styles.input, { color: colors.foreground }]}
placeholder="Enter your API key" placeholder="Enter your API key"
placeholderTextColor={colors.mutedForeground} placeholderTextColor={colors.mutedForeground}
value={inputKey} value={form.key}
onChangeText={(t) => { setInputKey(t); setValidationStatus("idle"); setErrorDetail(""); }} onChangeText={(t) => patchForm({ key: t })}
secureTextEntry={!showKey} secureTextEntry={!showKey}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
/> />
<TouchableOpacity onPress={() => setShowKey((v) => !v)} activeOpacity={0.7}> <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> </TouchableOpacity>
</View> </View>
{validationStatus === "ok" && ( {validationStatus === "ok" && (
<View style={styles.validationRow}> <View style={styles.validRow}>
<Feather name="check-circle" size={13} color={colors.success} /> <Feather name="check-circle" size={13} color={colors.success} />
<Text style={[styles.validationText, { color: colors.success }]}> <Text style={[styles.validText, { color: colors.success }]}>Connection successful</Text>
Connection successful
</Text>
</View> </View>
)} )}
@@ -207,39 +289,42 @@ export default function SettingsScreen() {
<View style={[styles.errorBox, { backgroundColor: colors.error + "12", borderColor: colors.error + "30" }]}> <View style={[styles.errorBox, { backgroundColor: colors.error + "12", borderColor: colors.error + "30" }]}>
<View style={styles.errorHeader}> <View style={styles.errorHeader}>
<Feather name="x-circle" size={13} color={colors.error} /> <Feather name="x-circle" size={13} color={colors.error} />
<Text style={[styles.errorTitle, { color: colors.error }]}> <Text style={[styles.errorTitle, { color: colors.error }]}>Could not connect</Text>
Could not connect
</Text>
</View> </View>
{!!errorDetail && ( {!!errorDetail && (
<ScrollView style={styles.errorScroll} nestedScrollEnabled> <ScrollView style={styles.errorScroll} nestedScrollEnabled>
<Text style={[styles.errorDetail, { color: colors.error }]} selectable> <Text style={[styles.errorDetail, { color: colors.error }]} selectable>{errorDetail}</Text>
{errorDetail}
</Text>
</ScrollView> </ScrollView>
)} )}
</View> </View>
)} )}
</View> </View>
{/* Form actions */}
<TouchableOpacity <TouchableOpacity
onPress={handleValidate} onPress={handleValidate}
activeOpacity={0.8} activeOpacity={0.8}
disabled={validating} disabled={validating}
style={[styles.validateBtn, { backgroundColor: colors.card, borderColor: colors.border }]} style={[styles.validateBtn, { backgroundColor: colors.background, borderColor: colors.border }]}
> >
{validating ? ( {validating ? (
<ActivityIndicator color={colors.primary} size="small" /> <ActivityIndicator color={colors.primary} size="small" />
) : ( ) : (
<> <>
<Feather name="wifi" size={15} color={colors.primary} /> <Feather name="wifi" size={15} color={colors.primary} />
<Text style={[styles.validateText, { color: colors.primary }]}> <Text style={[styles.validateText, { color: colors.primary }]}>Test Connection</Text>
Test Connection
</Text>
</> </>
)} )}
</TouchableOpacity> </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 <TouchableOpacity
onPress={handleSave} onPress={handleSave}
activeOpacity={0.85} activeOpacity={0.85}
@@ -252,169 +337,75 @@ export default function SettingsScreen() {
<> <>
<Feather name="save" size={15} color={colors.primaryForeground} /> <Feather name="save" size={15} color={colors.primaryForeground} />
<Text style={[styles.saveText, { color: colors.primaryForeground }]}> <Text style={[styles.saveText, { color: colors.primaryForeground }]}>
Save Settings {form.id ? "Update" : "Save"}
</Text> </Text>
</> </>
)} )}
</TouchableOpacity> </TouchableOpacity>
</View>
{isConfigured && ( </View>
<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 style={styles.footer}>
<Text style={[styles.footerText, { color: colors.mutedForeground }]}> <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> </Text>
</View>
</KeyboardAwareScrollView> </KeyboardAwareScrollView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: { paddingHorizontal: 20, gap: 14 },
paddingHorizontal: 20,
gap: 16,
},
banner: { banner: {
flexDirection: "row", flexDirection: "row", alignItems: "center", gap: 10,
alignItems: "center", paddingHorizontal: 14, paddingVertical: 12, borderRadius: 12, borderWidth: 1,
gap: 10,
paddingHorizontal: 14,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
},
bannerText: {
fontSize: 13,
fontFamily: "Inter_500Medium",
flex: 1,
}, },
bannerText: { fontSize: 13, fontFamily: "Inter_500Medium", flex: 1 },
connectedBadge: { connectedBadge: {
flexDirection: "row", flexDirection: "row", alignItems: "center", gap: 6,
alignItems: "center", paddingHorizontal: 12, paddingVertical: 8, borderRadius: 10, borderWidth: 1, alignSelf: "flex-start",
gap: 6,
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 10,
borderWidth: 1,
alignSelf: "flex-start",
}, },
connectedText: { connectedText: { fontSize: 12, fontFamily: "Inter_600SemiBold" },
fontSize: 12, wsCard: { borderRadius: 14, borderWidth: 1, overflow: "hidden" },
fontFamily: "Inter_600SemiBold", wsCardHeader: { flexDirection: "row", alignItems: "center", paddingHorizontal: 14, paddingVertical: 12 },
}, wsCardLeft: { flex: 1, flexDirection: "row", alignItems: "center", gap: 12 },
section: { wsIcon: { width: 32, height: 32, borderRadius: 10, alignItems: "center", justifyContent: "center" },
gap: 8, wsName: { fontSize: 14, fontFamily: "Inter_600SemiBold" },
}, wsUrl: { fontSize: 11, fontFamily: "Inter_400Regular", marginTop: 1 },
label: { wsCardActions: { flexDirection: "row", gap: 4 },
fontSize: 11, iconBtn: { padding: 8 },
fontFamily: "Inter_600SemiBold", addBtn: {
letterSpacing: 0.8, flexDirection: "row", alignItems: "center", justifyContent: "center",
marginLeft: 2, 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: { inputWrap: {
flexDirection: "row", flexDirection: "row", alignItems: "center",
alignItems: "center", borderRadius: 10, borderWidth: 1, paddingHorizontal: 12, paddingVertical: 11, gap: 10,
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,
}, },
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: { validateBtn: {
flexDirection: "row", flexDirection: "row", alignItems: "center", justifyContent: "center",
alignItems: "center", gap: 8, paddingVertical: 11, borderRadius: 10, borderWidth: 1,
justifyContent: "center",
gap: 8,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
},
validateText: {
fontSize: 14,
fontFamily: "Inter_600SemiBold",
}, },
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: { saveBtn: {
flexDirection: "row", flex: 2, flexDirection: "row", alignItems: "center", justifyContent: "center",
alignItems: "center", gap: 8, paddingVertical: 12, borderRadius: 10,
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,
}, },
saveText: { fontSize: 14, fontFamily: "Inter_600SemiBold" },
footerText: { fontSize: 12, fontFamily: "Inter_400Regular", textAlign: "center", lineHeight: 18, marginTop: 4 },
}); });
Binary file not shown.

Before

Width:  |  Height:  |  Size: 652 KiB

After

Width:  |  Height:  |  Size: 49 KiB

@@ -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" },
});
@@ -12,6 +12,7 @@ import {
import { Swipeable } from "react-native-gesture-handler"; import { Swipeable } from "react-native-gesture-handler";
import { useColors } from "@/hooks/useColors"; import { useColors } from "@/hooks/useColors";
import { PostizPost } from "@/context/PostizContext"; import { PostizPost } from "@/context/PostizContext";
import { stripHtml } from "@/lib/stripHtml";
import { StatusBadge } from "./StatusBadge"; import { StatusBadge } from "./StatusBadge";
interface PostCardProps { interface PostCardProps {
@@ -118,10 +119,11 @@ export function PostCard({ post, onDelete, onLongPress, onReschedule }: PostCard
: undefined; : undefined;
const integrations = post.integrations ?? (post.integration ? [post.integration] : []); const integrations = post.integrations ?? (post.integration ? [post.integration] : []);
const plainContent = stripHtml(post.content);
const truncatedContent = const truncatedContent =
post.content.length > 140 plainContent.length > 140
? post.content.slice(0, 140) + "…" ? plainContent.slice(0, 140) + "…"
: post.content; : plainContent;
return ( return (
<Swipeable <Swipeable
@@ -5,14 +5,22 @@ import React, {
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
useRef,
useState, useState,
} from "react"; } from "react";
const API_KEY_STORAGE = "postiz_api_key"; const WORKSPACES_KEY = "postiz_workspaces_v2";
const BASE_URL_STORAGE = "postiz_base_url"; 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 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 { export interface PostizIntegration {
id: string; id: string;
name: string; name: string;
@@ -45,129 +53,129 @@ export interface PostizUploadResult {
} }
interface PostizContextValue { interface PostizContextValue {
apiKey: string; workspaces: PostizWorkspace[];
baseUrl: string;
isConfigured: boolean; isConfigured: boolean;
isLoading: boolean; isLoading: boolean;
unauthorized: boolean; clients: Record<string, AxiosInstance>;
clearUnauthorized: () => void; 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; client: AxiosInstance | null;
saveSettings: (apiKey: string, baseUrl: string) => Promise<void>; apiKey: string;
clearSettings: () => Promise<void>; baseUrl: string;
} }
const PostizContext = createContext<PostizContextValue>({ const PostizContext = createContext<PostizContextValue>({
apiKey: "", workspaces: [],
baseUrl: DEFAULT_BASE_URL,
isConfigured: false, isConfigured: false,
isLoading: true, isLoading: true,
unauthorized: false, clients: {},
clearUnauthorized: () => {}, addWorkspace: async () => {},
updateWorkspace: async () => {},
removeWorkspace: async () => {},
client: null, client: null,
saveSettings: async () => {}, apiKey: "",
clearSettings: async () => {}, baseUrl: DEFAULT_BASE_URL,
}); });
function createClient( function makeClient(ws: PostizWorkspace): AxiosInstance {
apiKey: string, const baseURL = ws.baseUrl.endsWith("/") ? ws.baseUrl : ws.baseUrl + "/";
baseUrl: string,
onUnauthorized?: () => void
): AxiosInstance {
const normalizedUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
const instance = axios.create({ const instance = axios.create({
baseURL: normalizedUrl, baseURL,
headers: { headers: { Authorization: ws.apiKey, "Content-Type": "application/json" },
Authorization: apiKey,
"Content-Type": "application/json",
},
timeout: 15000, timeout: 15000,
}); });
instance.interceptors.request.use((config) => { 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; return config;
}); });
instance.interceptors.response.use(
(res) => res,
(err) => {
if (axios.isAxiosError(err) && err.response?.status === 401) {
onUnauthorized?.();
}
return Promise.reject(err);
}
);
return instance; 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 }) { export function PostizProvider({ children }: { children: React.ReactNode }) {
const [apiKey, setApiKey] = useState(""); const [workspaces, setWorkspaces] = useState<PostizWorkspace[]>([]);
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL); const [clients, setClients] = useState<Record<string, AxiosInstance>>({});
const [isLoading, setIsLoading] = useState(true); 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(() => { useEffect(() => {
(async () => { (async () => {
try { try {
const storedKey = await SecureStore.getItemAsync(API_KEY_STORAGE); const stored = await SecureStore.getItemAsync(WORKSPACES_KEY);
const storedUrl = await SecureStore.getItemAsync(BASE_URL_STORAGE); if (stored) {
if (storedKey) { const list: PostizWorkspace[] = JSON.parse(stored);
const url = (storedUrl || DEFAULT_BASE_URL).replace(/\/$/, ""); setWorkspaces(list);
setApiKey(storedKey); setClients(buildClients(list));
setBaseUrl(url); } else {
setClient(() => createClient(storedKey, url, handleUnauthorized)); // 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 { } catch {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
})(); })();
}, [handleUnauthorized]); }, []);
const saveSettings = useCallback( const persist = useCallback(async (list: PostizWorkspace[]) => {
async (newApiKey: string, newBaseUrl: string) => { await SecureStore.setItemAsync(WORKSPACES_KEY, JSON.stringify(list));
await SecureStore.setItemAsync(API_KEY_STORAGE, newApiKey); setWorkspaces(list);
await SecureStore.setItemAsync(BASE_URL_STORAGE, newBaseUrl); setClients(buildClients(list));
setApiKey(newApiKey); }, []);
setBaseUrl(newBaseUrl);
clearUnauthorized(); const addWorkspace = useCallback(
setClient(() => createClient(newApiKey, newBaseUrl, handleUnauthorized)); async (ws: Omit<PostizWorkspace, "id">) => {
await persist([...workspaces, { ...ws, id: Date.now().toString() }]);
}, },
[handleUnauthorized, clearUnauthorized] [workspaces, persist]
); );
const clearSettings = useCallback(async () => { const updateWorkspace = useCallback(
await SecureStore.deleteItemAsync(API_KEY_STORAGE); async (ws: PostizWorkspace) => {
await SecureStore.deleteItemAsync(BASE_URL_STORAGE); await persist(workspaces.map((w) => (w.id === ws.id ? ws : w)));
setApiKey(""); },
setBaseUrl(DEFAULT_BASE_URL); [workspaces, persist]
setClient(null); );
clearUnauthorized();
}, [clearUnauthorized]); const removeWorkspace = useCallback(
async (id: string) => {
await persist(workspaces.filter((w) => w.id !== id));
},
[workspaces, persist]
);
const primaryWorkspace = workspaces[0] ?? null;
return ( return (
<PostizContext.Provider <PostizContext.Provider
value={{ value={{
apiKey, workspaces,
baseUrl, isConfigured: workspaces.length > 0,
isConfigured: !!apiKey,
isLoading, isLoading,
unauthorized, clients,
clearUnauthorized, addWorkspace,
client, updateWorkspace,
saveSettings, removeWorkspace,
clearSettings, client: primaryWorkspace ? (clients[primaryWorkspace.id] ?? null) : null,
apiKey: primaryWorkspace?.apiKey ?? "",
baseUrl: primaryWorkspace?.baseUrl ?? DEFAULT_BASE_URL,
}} }}
> >
{children} {children}
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { usePostiz } from "@/context/PostizContext"; import { usePostiz } from "@/context/PostizContext";
import { PostizPost } from "@/context/PostizContext"; import { PostizPost } from "@/context/PostizContext";
import { stripHtml } from "@/lib/stripHtml";
const POLL_INTERVAL_MS = 15 * 60 * 1000; const POLL_INTERVAL_MS = 15 * 60 * 1000;
const SEEN_KEY = "postiz_seen_statuses"; const SEEN_KEY = "postiz_seen_statuses";
@@ -44,10 +45,7 @@ async function sendStatusNotification(post: PostizPost) {
await Notifications.scheduleNotificationAsync({ await Notifications.scheduleNotificationAsync({
content: { content: {
title: isError ? "Post failed to publish" : "Post published!", title: isError ? "Post failed to publish" : "Post published!",
body: body: (() => { const t = stripHtml(post.content); return t.length > 80 ? t.slice(0, 80) + "…" : t; })(),
post.content.length > 80
? post.content.slice(0, 80) + "…"
: post.content,
data: { postId: post.id }, data: { postId: post.id },
}, },
trigger: null, trigger: null,
+19
View File
@@ -0,0 +1,19 @@
export function stripHtml(html: string): string {
// 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, " ");
// 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();
}
+1
View File
@@ -33,6 +33,7 @@
"expo-glass-effect": "~0.1.4", "expo-glass-effect": "~0.1.4",
"expo-haptics": "~15.0.8", "expo-haptics": "~15.0.8",
"expo-image": "~3.0.11", "expo-image": "~3.0.11",
"expo-image-manipulator": "~13.0.6",
"expo-image-picker": "~17.0.9", "expo-image-picker": "~17.0.9",
"expo-linear-gradient": "~15.0.8", "expo-linear-gradient": "~15.0.8",
"expo-linking": "~8.0.10", "expo-linking": "~8.0.10",