5 Commits

Author SHA1 Message Date
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
9 changed files with 630 additions and 504 deletions
+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
+170 -278
View File
@@ -4,6 +4,7 @@ import DateTimePicker from "@react-native-community/datetimepicker";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
import { Image } from "expo-image"; import { Image } from "expo-image";
import * as ImageManipulator from "expo-image-manipulator";
import * as ImagePicker from "expo-image-picker"; import * as ImagePicker from "expo-image-picker";
import { fetch as expoFetch } from "expo/fetch"; import { fetch as expoFetch } from "expo/fetch";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
@@ -23,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,
@@ -36,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();
@@ -48,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);
@@ -56,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]);
@@ -129,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]
@@ -138,38 +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: 0.85, quality: 1,
}); });
if (!result.canceled && result.assets[0]) { if (!result.canceled && result.assets.length > 0) {
setImageUri(result.assets[0].uri); const MAX_DIM = 1920;
setExistingMedia([]); const processed: string[] = [];
for (const asset of result.assets) {
const w = asset.width ?? 0;
const h = asset.height ?? 0;
if (w > MAX_DIM || h > MAX_DIM) {
const landscape = w >= h;
const resized = await ImageManipulator.manipulateAsync(
asset.uri,
[{ resize: landscape ? { width: MAX_DIM } : { height: MAX_DIM } }],
{ compress: 0.85, format: ImageManipulator.SaveFormat.JPEG }
);
processed.push(resized.uri);
} else {
processed.push(asset.uri);
}
}
setMediaItems((prev) =>
[...prev, ...processed.map((uri): MediaItem => ({ type: "local", uri }))].slice(0, MAX_IMAGES)
);
} }
}; };
const removeImage = () => { const removeMediaItem = (index: number) => {
setImageUri(null); setMediaItems((prev) => prev.filter((_, i) => i !== index));
setExistingMedia([]);
}; };
const uploadImage = async (): Promise<PostizUploadResult> => { 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);
@@ -184,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);
} }
@@ -203,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);
@@ -229,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,
}); });
@@ -240,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;
@@ -270,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" });
@@ -290,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={[
@@ -317,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?"
@@ -369,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 }} />
@@ -426,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}
@@ -449,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} />
@@ -465,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} />
@@ -536,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>
@@ -558,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",
},
}); });
+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",