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, '-') }}
File diff suppressed because it is too large Load Diff
+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();
} }