3 Commits

Author SHA1 Message Date
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
6 changed files with 609 additions and 521 deletions
+3 -3
View File
@@ -106,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, '-') }}
+157 -281
View File
@@ -24,9 +24,12 @@ 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 { MediaLibraryModal } from "@/components/MediaLibraryModal";
import { PostizIntegration, PostizUploadResult, usePostiz } from "@/context/PostizContext"; import { PostizIntegration, PostizUploadResult, 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,
@@ -37,6 +40,16 @@ const NETWORK_CHAR_LIMITS: Record<string, number> = {
tiktok: 2200, tiktok: 2200,
}; };
type MediaItem =
| { type: "local"; uri: string }
| { type: "uploaded"; id: string; path: string };
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();
@@ -49,6 +62,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);
@@ -57,22 +71,19 @@ 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) }]); setMediaItems([{ type: "uploaded", id: String(prefillImageId), path: String(prefillImagePath) }]);
setImageUri(String(prefillImagePath));
} }
}, [prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId]); }, [prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId]);
@@ -130,8 +141,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]
@@ -139,53 +148,68 @@ 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: 1, quality: 1,
}); });
if (!result.canceled && result.assets[0]) { if (!result.canceled && result.assets.length > 0) {
const asset = result.assets[0];
const MAX_DIM = 1920; const MAX_DIM = 1920;
const processed: string[] = [];
for (const asset of result.assets) {
const w = asset.width ?? 0; const w = asset.width ?? 0;
const h = asset.height ?? 0; const h = asset.height ?? 0;
const needsResize = w > MAX_DIM || h > MAX_DIM; if (w > MAX_DIM || h > MAX_DIM) {
if (needsResize) {
const landscape = w >= h; const landscape = w >= h;
const resized = await ImageManipulator.manipulateAsync( const resized = await ImageManipulator.manipulateAsync(
asset.uri, asset.uri,
[{ resize: landscape ? { width: MAX_DIM } : { height: MAX_DIM } }], [{ resize: landscape ? { width: MAX_DIM } : { height: MAX_DIM } }],
{ compress: 0.85, format: ImageManipulator.SaveFormat.JPEG } { compress: 0.85, format: ImageManipulator.SaveFormat.JPEG }
); );
setImageUri(resized.uri); processed.push(resized.uri);
} else { } else {
setImageUri(asset.uri); processed.push(asset.uri);
} }
setExistingMedia([]); }
setMediaItems((prev) =>
[...prev, ...processed.map((uri): MediaItem => ({ type: "local", uri }))].slice(0, MAX_IMAGES)
);
} }
}; };
const removeImage = () => { const removeMediaItem = (index: number) => {
setImageUri(null); setMediaItems((prev) => prev.filter((_, i) => i !== index));
setExistingMedia([]);
}; };
const uploadImage = async (): Promise<PostizUploadResult> => { const buildMediaPayload = async (): Promise<Array<{ id: string; path: string }>> => {
setUploading(true); setUploading(true);
try { try {
const result: Array<{ id: string; path: string }> = [];
for (const item of mediaItems) {
if (item.type === "uploaded") {
result.push({ id: item.id, path: item.path });
continue;
}
const formData = new FormData(); const formData = new FormData();
if (Platform.OS === "web") { if (Platform.OS === "web") {
const response = await expoFetch(imageUri!); const response = await expoFetch(item.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: item.uri,
name: "upload.jpg", name: "upload.jpg",
type: "image/jpeg", type: "image/jpeg",
} as unknown as Blob); } as unknown as Blob);
@@ -200,7 +224,10 @@ export default function ComposeScreen() {
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(`Upload HTTP ${uploadRes.status}: ${raw.slice(0, 200)}`);
} }
return await uploadRes.json() as PostizUploadResult; const uploaded = (await uploadRes.json()) as PostizUploadResult;
result.push({ id: uploaded.id, path: uploaded.path });
}
return result;
} finally { } finally {
setUploading(false); setUploading(false);
} }
@@ -219,25 +246,16 @@ 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 }> = []; const media = mediaItems.length > 0 ? await buildMediaPayload() : [];
const isLocalFile = imageUri && !imageUri.startsWith("http");
if (imageUri && isLocalFile) {
const uploaded = await uploadImage();
media = [{ id: uploaded.id, path: uploaded.path }];
} else if (existingMedia.length > 0) {
media = existingMedia;
}
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: selectedChannels.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", `${baseUrl}/posts`, body);
@@ -245,10 +263,7 @@ export default function ComposeScreen() {
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
const res = await globalThis.fetch(`${baseUrl}/posts`, { const res = await globalThis.fetch(`${baseUrl}/posts`, {
method: "POST", method: "POST",
headers: { headers: { Authorization: apiKey, "Content-Type": "application/json" },
Authorization: apiKey,
"Content-Type": "application/json",
},
body, body,
}); });
@@ -256,7 +271,7 @@ 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] error body:", raw);
detail = raw.slice(0, 500); detail = raw.slice(0, 500);
} catch { } catch {
detail = res.statusText; detail = res.statusText;
@@ -286,18 +301,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" });
@@ -306,17 +316,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 your API key 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={[
@@ -333,24 +340,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?"
@@ -385,39 +385,65 @@ 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, 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 }]}> <Text style={[styles.sectionLabel, { color: colors.mutedForeground }]}>CHANNELS</Text>
CHANNELS
</Text>
{loadingIntegrations ? ( {loadingIntegrations ? (
<ActivityIndicator color={colors.primary} style={{ marginVertical: 8 }} /> <ActivityIndicator color={colors.primary} style={{ marginVertical: 8 }} />
@@ -442,17 +468,10 @@ export default function ComposeScreen() {
</ScrollView> </ScrollView>
)} )}
<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}
@@ -465,14 +484,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} />
@@ -481,14 +494,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} />
@@ -552,21 +559,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>
@@ -574,179 +574,55 @@ export default function ComposeScreen() {
)} )}
</TouchableOpacity> </TouchableOpacity>
</KeyboardAwareScrollView> </KeyboardAwareScrollView>
<MediaLibraryModal
visible={showMediaLibrary}
baseUrl={baseUrl}
apiKey={apiKey}
maxSelect={MAX_IMAGES - mediaItems.length}
onClose={() => setShowMediaLibrary(false)}
onSelect={(items) => {
setMediaItems((prev) =>
[...prev, ...items.map((i): MediaItem => ({ type: "uploaded", id: i.id, path: i.path }))].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", draftBanner: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 14, paddingVertical: 10, borderRadius: 12, borderWidth: 1 },
gap: 10, draftBannerText: { flex: 1, fontSize: 13, fontFamily: "Inter_400Regular" },
}, draftBannerAction: { fontSize: 13, fontFamily: "Inter_600SemiBold" },
textArea: { draftBtn: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 6, paddingVertical: 10, borderRadius: 12, borderWidth: 1 },
borderRadius: 14, draftBtnText: { fontSize: 13, fontFamily: "Inter_500Medium" },
borderWidth: 1, imageRow: { gap: 10, paddingRight: 4 },
padding: 14, imageThumbWrap: { position: "relative" },
minHeight: 140, imageThumb: { width: 100, height: 100, borderRadius: 10, borderWidth: 1 },
}, removeImg: { position: "absolute", top: 4, right: 4, width: 20, height: 20, borderRadius: 10, alignItems: "center", justifyContent: "center" },
textInput: { uploadedBadge: { position: "absolute", bottom: 4, left: 4, width: 16, height: 16, borderRadius: 8, alignItems: "center", justifyContent: "center" },
fontSize: 15, mediaBtnsRow: { flexDirection: "row", gap: 8 },
fontFamily: "Inter_400Regular", mediaBtn: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 14, paddingVertical: 10, borderRadius: 10, borderWidth: 1 },
lineHeight: 22, mediaBtnText: { fontSize: 13, fontFamily: "Inter_500Medium" },
minHeight: 100, sectionLabel: { fontSize: 11, fontFamily: "Inter_600SemiBold", letterSpacing: 0.8, marginBottom: -6 },
}, sectionTitle: { fontSize: 18, fontFamily: "Inter_600SemiBold" },
charCountRow: { hint: { fontSize: 13, fontFamily: "Inter_400Regular", textAlign: "center" },
flexDirection: "row", channelList: { flexDirection: "row", gap: 8, flexWrap: "wrap" },
justifyContent: "flex-end", scheduleRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 16, paddingVertical: 12, borderRadius: 12, borderWidth: 1 },
alignItems: "center", scheduleRowLeft: { flexDirection: "row", alignItems: "center", gap: 10 },
gap: 6, scheduleLabel: { fontSize: 15, fontFamily: "Inter_500Medium" },
marginTop: 4, dateTimeRow: { flexDirection: "row", gap: 10 },
}, dateBtn: { flex: 1, flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12, paddingVertical: 10, borderRadius: 10, borderWidth: 1 },
charCountLabel: { dateBtnText: { fontSize: 13, fontFamily: "Inter_500Medium" },
fontSize: 10, submitBtn: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 8, paddingVertical: 14, borderRadius: 14, marginTop: 4 },
fontFamily: "Inter_400Regular", submitText: { fontSize: 15, fontFamily: "Inter_600SemiBold" },
},
charCount: {
fontSize: 11,
fontFamily: "Inter_400Regular",
},
draftBanner: {
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 12,
borderWidth: 1,
},
draftBannerText: {
flex: 1,
fontSize: 13,
fontFamily: "Inter_400Regular",
},
draftBannerAction: {
fontSize: 13,
fontFamily: "Inter_600SemiBold",
},
draftBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 6,
paddingVertical: 10,
borderRadius: 12,
borderWidth: 1,
},
draftBtnText: {
fontSize: 13,
fontFamily: "Inter_500Medium",
},
imagePreviewWrap: {
position: "relative",
alignSelf: "flex-start",
},
imagePreview: {
width: 120,
height: 120,
borderRadius: 10,
borderWidth: 1,
},
removeImg: {
position: "absolute",
top: 4,
right: 4,
width: 20,
height: 20,
borderRadius: 10,
alignItems: "center",
justifyContent: "center",
},
mediaBtn: {
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 10,
borderWidth: 1,
alignSelf: "flex-start",
},
mediaBtnText: {
fontSize: 13,
fontFamily: "Inter_500Medium",
},
sectionLabel: {
fontSize: 11,
fontFamily: "Inter_600SemiBold",
letterSpacing: 0.8,
marginBottom: -6,
},
sectionTitle: {
fontSize: 18,
fontFamily: "Inter_600SemiBold",
},
hint: {
fontSize: 13,
fontFamily: "Inter_400Regular",
textAlign: "center",
},
channelList: {
flexDirection: "row",
gap: 8,
flexWrap: "wrap",
},
scheduleRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
},
scheduleRowLeft: {
flexDirection: "row",
alignItems: "center",
gap: 10,
},
scheduleLabel: {
fontSize: 15,
fontFamily: "Inter_500Medium",
},
dateTimeRow: {
flexDirection: "row",
gap: 10,
},
dateBtn: {
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 10,
borderWidth: 1,
},
dateBtnText: {
fontSize: 13,
fontFamily: "Inter_500Medium",
},
submitBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
paddingVertical: 14,
borderRadius: 14,
marginTop: 4,
},
submitText: {
fontSize: 15,
fontFamily: "Inter_600SemiBold",
},
}); });
+1 -1
View File
@@ -155,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(","),
}, },
}); });
@@ -0,0 +1,209 @@
import { Feather } from "@expo/vector-icons";
import { Image } from "expo-image";
import React, { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
FlatList,
Modal,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useColors } from "@/hooks/useColors";
interface MediaItem {
id: string;
path: string;
createdAt?: string;
}
interface Props {
visible: boolean;
baseUrl: string;
apiKey: string;
maxSelect: number;
onClose: () => void;
onSelect: (items: MediaItem[]) => 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, baseUrl, apiKey, maxSelect, onClose, onSelect }: Props) {
const colors = useColors();
const insets = useSafeAreaInsets();
const [items, setItems] = useState<MediaItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const load = useCallback(async () => {
if (!baseUrl || !apiKey) return;
setLoading(true);
setError(null);
try {
// eslint-disable-next-line no-undef
const res = await globalThis.fetch(`${baseUrl}/media`, {
headers: { Authorization: apiKey },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const list: MediaItem[] = 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);
}
}, [baseUrl, apiKey]);
useEffect(() => {
if (visible) {
setSelected(new Set());
load();
}
}, [visible, 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 = () => {
const chosen = items.filter((i) => selected.has(i.id));
onSelect(chosen);
};
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
<View style={[styles.root, { backgroundColor: colors.background, paddingTop: insets.top }]}>
<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>
{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, 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" },
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",
},
});
@@ -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,
+12 -7
View File
@@ -1,14 +1,19 @@
export function stripHtml(html: string): string { export function stripHtml(html: string): string {
return html // Decode entities first so encoded tags like &lt;p&gt; are also stripped
.replace(/<br\s*\/?>/gi, "\n") let s = html
.replace(/<\/p>/gi, "\n")
.replace(/<[^>]+>/g, "")
.replace(/&amp;/g, "&") .replace(/&amp;/g, "&")
.replace(/&lt;/g, "<") .replace(/&lt;/g, "<")
.replace(/&gt;/g, ">") .replace(/&gt;/g, ">")
.replace(/&quot;/g, '"') .replace(/&quot;/g, '"')
.replace(/&#39;/g, "'") .replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ") .replace(/&nbsp;/g, " ");
.replace(/\n{3,}/g, "\n\n") // Block-level tags → newlines
.trim(); 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();
} }