7 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
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
billisdead f6fcf35cf8 fix(ci): add caching + remove unnecessary expo-cli install to fix 30m timeout
Release APK / build (push) Has been cancelled
- Cache pnpm store, Android NDK (28.2.13676358, ~1.5 GB), and Gradle
- Skip sdkmanager NDK install on cache hit
- Remove global expo-cli install (already in devDeps as @expo/cli)
- Increase timeout 30m → 60m for first cold build

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 10:29:06 +02:00
9 changed files with 668 additions and 512 deletions
+41 -8
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: 30 timeout-minutes: 60
permissions:
contents: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -27,22 +30,52 @@ jobs:
with: with:
version: 9 version: 9
- name: Get pnpm store path
id: pnpm-cache
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Install dependencies - name: Install dependencies
working-directory: artifacts/postiz-mobile working-directory: artifacts/postiz-mobile
run: pnpm install --no-frozen-lockfile run: pnpm install --no-frozen-lockfile
- name: Install Expo CLI
run: pnpm add -g expo-cli@latest || true
- name: Set up Android SDK - name: Set up Android SDK
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
- name: Accept Android SDK licenses - name: Accept Android SDK licenses
run: yes | sdkmanager --licenses || true run: yes | sdkmanager --licenses || true
- name: Cache Android NDK
id: ndk-cache
uses: actions/cache@v4
with:
path: /usr/local/lib/android/sdk/ndk/28.2.13676358
key: ndk-28.2.13676358-v1
- name: Install SDK components - name: Install SDK components
run: | run: |
sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0" "ndk;28.2.13676358" sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
if [ "${{ steps.ndk-cache.outputs.cache-hit }}" != "true" ]; then
sdkmanager "ndk;28.2.13676358"
fi
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('artifacts/postiz-mobile/package.json') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Decode keystore - name: Decode keystore
run: | run: |
@@ -73,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);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 652 KiB

After

Width:  |  Height:  |  Size: 49 KiB

@@ -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",
},
});
@@ -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
@@ -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",