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, '-') }}
File diff suppressed because it is too large Load Diff
+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);
+279 -288
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,281 +161,251 @@ 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 */}
<Text style={[styles.label, { color: colors.mutedForeground }]}>BASE URL</Text> {workspaces.map((ws) => (
<View style={[styles.inputWrap, { backgroundColor: colors.card, borderColor: colors.border }]}> <View key={ws.id} style={[styles.wsCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
<Feather name="globe" size={16} color={colors.mutedForeground} style={styles.inputIcon} /> <View style={styles.wsCardHeader}>
<TextInput <View style={styles.wsCardLeft}>
style={[styles.input, { color: colors.foreground }]} <View style={[styles.wsIcon, { backgroundColor: colors.primary + "18" }]}>
placeholder="https://postiz.example.com/api/public/v1" <Feather name="briefcase" size={14} color={colors.primary} />
placeholderTextColor={colors.mutedForeground} </View>
value={inputUrl} <View>
onChangeText={(t) => { setInputUrl(t); setValidationStatus("idle"); setErrorDetail(""); }} <Text style={[styles.wsName, { color: colors.foreground }]}>{ws.name}</Text>
autoCapitalize="none" <Text style={[styles.wsUrl, { color: colors.mutedForeground }]} numberOfLines={1}>
autoCorrect={false} {ws.baseUrl.replace(/^https?:\/\//, "").replace(/\/api.*$/, "")}
keyboardType="url"
/>
</View>
</View>
<View style={styles.section}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>API KEY</Text>
<View style={[styles.inputWrap, { backgroundColor: colors.card, borderColor: colors.border }]}>
<Feather name="key" size={16} color={colors.mutedForeground} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.foreground }]}
placeholder="Enter your API key"
placeholderTextColor={colors.mutedForeground}
value={inputKey}
onChangeText={(t) => { setInputKey(t); setValidationStatus("idle"); setErrorDetail(""); }}
secureTextEntry={!showKey}
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity onPress={() => setShowKey((v) => !v)} activeOpacity={0.7}>
<Feather name={showKey ? "eye-off" : "eye"} size={16} color={colors.mutedForeground} />
</TouchableOpacity>
</View>
{validationStatus === "ok" && (
<View style={styles.validationRow}>
<Feather name="check-circle" size={13} color={colors.success} />
<Text style={[styles.validationText, { color: colors.success }]}>
Connection successful
</Text>
</View>
)}
{validationStatus === "error" && (
<View style={[styles.errorBox, { backgroundColor: colors.error + "12", borderColor: colors.error + "30" }]}>
<View style={styles.errorHeader}>
<Feather name="x-circle" size={13} color={colors.error} />
<Text style={[styles.errorTitle, { color: colors.error }]}>
Could not connect
</Text>
</View>
{!!errorDetail && (
<ScrollView style={styles.errorScroll} nestedScrollEnabled>
<Text style={[styles.errorDetail, { color: colors.error }]} selectable>
{errorDetail}
</Text> </Text>
</ScrollView> </View>
)} </View>
<View style={styles.wsCardActions}>
<TouchableOpacity onPress={() => openEdit(ws)} activeOpacity={0.7} style={styles.iconBtn}>
<Feather name="edit-2" size={15} color={colors.mutedForeground} />
</TouchableOpacity>
<TouchableOpacity onPress={() => handleDelete(ws)} activeOpacity={0.7} style={styles.iconBtn}>
<Feather name="trash-2" size={15} color={colors.destructive} />
</TouchableOpacity>
</View>
</View> </View>
)} </View>
</View> ))}
<TouchableOpacity {/* Add workspace button */}
onPress={handleValidate} {!form && (
activeOpacity={0.8}
disabled={validating}
style={[styles.validateBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
>
{validating ? (
<ActivityIndicator color={colors.primary} size="small" />
) : (
<>
<Feather name="wifi" size={15} color={colors.primary} />
<Text style={[styles.validateText, { color: colors.primary }]}>
Test Connection
</Text>
</>
)}
</TouchableOpacity>
<TouchableOpacity
onPress={handleSave}
activeOpacity={0.85}
disabled={saving}
style={[styles.saveBtn, { backgroundColor: saving ? colors.muted : colors.primary }]}
>
{saving ? (
<ActivityIndicator color={colors.primaryForeground} size="small" />
) : (
<>
<Feather name="save" size={15} color={colors.primaryForeground} />
<Text style={[styles.saveText, { color: colors.primaryForeground }]}>
Save Settings
</Text>
</>
)}
</TouchableOpacity>
{isConfigured && (
<TouchableOpacity <TouchableOpacity
onPress={handleClear} onPress={openAdd}
activeOpacity={0.8} activeOpacity={0.8}
style={[styles.clearBtn, { borderColor: colors.destructive + "60" }]} style={[styles.addBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
> >
<Feather name="log-out" size={14} color={colors.destructive} /> <Feather name="plus" size={16} color={colors.primary} />
<Text style={[styles.clearText, { color: colors.destructive }]}>Disconnect</Text> <Text style={[styles.addBtnText, { color: colors.primary }]}>Add workspace</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
<View style={styles.footer}> {/* Add / Edit form */}
<Text style={[styles.footerText, { color: colors.mutedForeground }]}> {form && (
Your API key is stored securely on this device and never transmitted to third parties. <View style={[styles.formCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
</Text> <Text style={[styles.formTitle, { color: colors.foreground }]}>
</View> {form.id ? "Edit workspace" : "Add workspace"}
</Text>
{/* Name */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>NAME</Text>
<View style={[styles.inputWrap, { backgroundColor: colors.background, borderColor: colors.border }]}>
<Feather name="briefcase" size={15} color={colors.mutedForeground} />
<TextInput
style={[styles.input, { color: colors.foreground }]}
placeholder="My Client"
placeholderTextColor={colors.mutedForeground}
value={form.name}
onChangeText={(t) => patchForm({ name: t })}
autoCorrect={false}
/>
</View>
</View>
{/* Base URL */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>BASE URL</Text>
<View style={[styles.inputWrap, { backgroundColor: colors.background, borderColor: colors.border }]}>
<Feather name="globe" size={15} color={colors.mutedForeground} />
<TextInput
style={[styles.input, { color: colors.foreground }]}
placeholder="https://postiz.example.com/api/public/v1"
placeholderTextColor={colors.mutedForeground}
value={form.url}
onChangeText={(t) => patchForm({ url: t })}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
</View>
</View>
{/* API Key */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>API KEY</Text>
<View style={[styles.inputWrap, { backgroundColor: colors.background, borderColor: colors.border }]}>
<Feather name="key" size={15} color={colors.mutedForeground} />
<TextInput
style={[styles.input, { color: colors.foreground }]}
placeholder="Enter your API key"
placeholderTextColor={colors.mutedForeground}
value={form.key}
onChangeText={(t) => patchForm({ key: t })}
secureTextEntry={!showKey}
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity onPress={() => setShowKey((v) => !v)} activeOpacity={0.7}>
<Feather name={showKey ? "eye-off" : "eye"} size={15} color={colors.mutedForeground} />
</TouchableOpacity>
</View>
{validationStatus === "ok" && (
<View style={styles.validRow}>
<Feather name="check-circle" size={13} color={colors.success} />
<Text style={[styles.validText, { color: colors.success }]}>Connection successful</Text>
</View>
)}
{validationStatus === "error" && (
<View style={[styles.errorBox, { backgroundColor: colors.error + "12", borderColor: colors.error + "30" }]}>
<View style={styles.errorHeader}>
<Feather name="x-circle" size={13} color={colors.error} />
<Text style={[styles.errorTitle, { color: colors.error }]}>Could not connect</Text>
</View>
{!!errorDetail && (
<ScrollView style={styles.errorScroll} nestedScrollEnabled>
<Text style={[styles.errorDetail, { color: colors.error }]} selectable>{errorDetail}</Text>
</ScrollView>
)}
</View>
)}
</View>
{/* Form actions */}
<TouchableOpacity
onPress={handleValidate}
activeOpacity={0.8}
disabled={validating}
style={[styles.validateBtn, { backgroundColor: colors.background, borderColor: colors.border }]}
>
{validating ? (
<ActivityIndicator color={colors.primary} size="small" />
) : (
<>
<Feather name="wifi" size={15} color={colors.primary} />
<Text style={[styles.validateText, { color: colors.primary }]}>Test Connection</Text>
</>
)}
</TouchableOpacity>
<View style={styles.formBtnsRow}>
<TouchableOpacity
onPress={closeForm}
activeOpacity={0.8}
style={[styles.cancelBtn, { borderColor: colors.border }]}
>
<Text style={[styles.cancelText, { color: colors.mutedForeground }]}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleSave}
activeOpacity={0.85}
disabled={saving}
style={[styles.saveBtn, { backgroundColor: saving ? colors.muted : colors.primary }]}
>
{saving ? (
<ActivityIndicator color={colors.primaryForeground} size="small" />
) : (
<>
<Feather name="save" size={15} color={colors.primaryForeground} />
<Text style={[styles.saveText, { color: colors.primaryForeground }]}>
{form.id ? "Update" : "Save"}
</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
)}
<Text style={[styles.footerText, { color: colors.mutedForeground }]}>
API keys are stored securely on this device and never transmitted to third parties.
</Text>
</KeyboardAwareScrollView> </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",