diff --git a/artifacts/postiz-mobile/app/(tabs)/compose.tsx b/artifacts/postiz-mobile/app/(tabs)/compose.tsx index 513336f..86cf6d3 100644 --- a/artifacts/postiz-mobile/app/(tabs)/compose.tsx +++ b/artifacts/postiz-mobile/app/(tabs)/compose.tsx @@ -24,9 +24,12 @@ import { import { KeyboardAwareScrollView } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ChannelChip } from "@/components/ChannelChip"; +import { MediaLibraryModal } from "@/components/MediaLibraryModal"; import { PostizIntegration, PostizUploadResult, usePostiz } from "@/context/PostizContext"; import { useColors } from "@/hooks/useColors"; + const DRAFT_STORAGE_KEY = "postiz_local_draft"; +const MAX_IMAGES = 4; const NETWORK_CHAR_LIMITS: Record = { twitter: 280, x: 280, @@ -37,6 +40,16 @@ const NETWORK_CHAR_LIMITS: Record = { 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() { const colors = useColors(); const insets = useSafeAreaInsets(); @@ -49,6 +62,7 @@ export default function ComposeScreen() { prefillImagePath?: string; prefillImageId?: string; }>(); + const [content, setContent] = useState(""); const [selectedChannels, setSelectedChannels] = useState([]); const [postNow, setPostNow] = useState(false); @@ -57,22 +71,19 @@ export default function ComposeScreen() { ); const [showDatePicker, setShowDatePicker] = useState(false); const [showTimePicker, setShowTimePicker] = useState(false); - const [imageUri, setImageUri] = useState(null); - const [existingMedia, setExistingMedia] = useState>([]); + const [mediaItems, setMediaItems] = useState([]); + const [showMediaLibrary, setShowMediaLibrary] = useState(false); const [uploading, setUploading] = useState(false); const [submitting, setSubmitting] = useState(false); const [draftBanner, setDraftBanner] = useState(false); useEffect(() => { - if (prefillContent) { - setContent(String(prefillContent)); - } + if (prefillContent) setContent(String(prefillContent)); if (prefillIntegrationIds) { setSelectedChannels(String(prefillIntegrationIds).split(",").filter(Boolean)); } if (prefillImagePath && prefillImageId) { - setExistingMedia([{ id: String(prefillImageId), path: String(prefillImagePath) }]); - setImageUri(String(prefillImagePath)); + setMediaItems([{ type: "uploaded", id: String(prefillImageId), path: String(prefillImagePath) }]); } }, [prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId]); @@ -130,8 +141,6 @@ export default function ComposeScreen() { } catch {} }; - const dismissDraft = () => setDraftBanner(false); - const toggleChannel = (id: string) => { setSelectedChannels((prev) => prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id] @@ -139,68 +148,86 @@ export default function ComposeScreen() { }; 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(); if (status !== "granted") { Alert.alert("Permission required", "Allow access to your photo library."); return; } + const remaining = MAX_IMAGES - mediaItems.length; const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], + allowsMultipleSelection: true, + selectionLimit: remaining, allowsEditing: false, quality: 1, }); - if (!result.canceled && result.assets[0]) { - const asset = result.assets[0]; + if (!result.canceled && result.assets.length > 0) { const MAX_DIM = 1920; - const w = asset.width ?? 0; - const h = asset.height ?? 0; - const needsResize = w > MAX_DIM || h > MAX_DIM; - if (needsResize) { - 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 } - ); - setImageUri(resized.uri); - } else { - setImageUri(asset.uri); + 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); + } } - setExistingMedia([]); + setMediaItems((prev) => + [...prev, ...processed.map((uri): MediaItem => ({ type: "local", uri }))].slice(0, MAX_IMAGES) + ); } }; - const removeImage = () => { - setImageUri(null); - setExistingMedia([]); + const removeMediaItem = (index: number) => { + setMediaItems((prev) => prev.filter((_, i) => i !== index)); }; - const uploadImage = async (): Promise => { + const buildMediaPayload = async (): Promise> => { setUploading(true); try { - const formData = new FormData(); - if (Platform.OS === "web") { - const response = await expoFetch(imageUri!); - const blob = await response.blob(); - formData.append("file", blob, "upload.jpg"); - } else { - formData.append("file", { - uri: imageUri!, - name: "upload.jpg", - type: "image/jpeg", - } as unknown as Blob); + 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(); + if (Platform.OS === "web") { + const response = await expoFetch(item.uri); + const blob = await response.blob(); + formData.append("file", blob, "upload.jpg"); + } else { + formData.append("file", { + uri: item.uri, + name: "upload.jpg", + type: "image/jpeg", + } as unknown as Blob); + } + // eslint-disable-next-line no-undef + const uploadRes = await globalThis.fetch(`${baseUrl}/upload`, { + method: "POST", + headers: { Authorization: apiKey }, + body: formData, + }); + if (!uploadRes.ok) { + const raw = await uploadRes.text().catch(() => uploadRes.statusText); + throw new Error(`Upload HTTP ${uploadRes.status}: ${raw.slice(0, 200)}`); + } + const uploaded = (await uploadRes.json()) as PostizUploadResult; + result.push({ id: uploaded.id, path: uploaded.path }); } - // eslint-disable-next-line no-undef - const uploadRes = await globalThis.fetch(`${baseUrl}/upload`, { - method: "POST", - headers: { Authorization: apiKey }, - body: formData, - }); - if (!uploadRes.ok) { - const raw = await uploadRes.text().catch(() => uploadRes.statusText); - throw new Error(`Upload HTTP ${uploadRes.status}: ${raw.slice(0, 200)}`); - } - return await uploadRes.json() as PostizUploadResult; + return result; } finally { setUploading(false); } @@ -219,25 +246,16 @@ export default function ComposeScreen() { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); setSubmitting(true); try { - let media: Array<{ id: string; path: string }> = []; - 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 media = mediaItems.length > 0 ? await buildMediaPayload() : []; const payload = { type: postNow ? "now" : "schedule", date: postNow ? new Date().toISOString() : scheduleDate.toISOString(), shortLink: false, tags: [] as string[], - posts: selectedChannels.map((integrationId) => { - return { - integration: { id: integrationId }, - value: [{ content: content.trim(), image: media }], - }; - }), + posts: selectedChannels.map((integrationId) => ({ + integration: { id: integrationId }, + value: [{ content: content.trim(), image: media }], + })), }; const body = JSON.stringify(payload); console.log("[compose] POST", `${baseUrl}/posts`, body); @@ -245,10 +263,7 @@ export default function ComposeScreen() { // eslint-disable-next-line no-undef const res = await globalThis.fetch(`${baseUrl}/posts`, { method: "POST", - headers: { - Authorization: apiKey, - "Content-Type": "application/json", - }, + headers: { Authorization: apiKey, "Content-Type": "application/json" }, body, }); @@ -256,7 +271,7 @@ export default function ComposeScreen() { let detail = ""; try { const raw = await res.text(); - console.log("[compose] 400 body:", raw); + console.log("[compose] error body:", raw); detail = raw.slice(0, 500); } catch { detail = res.statusText; @@ -286,18 +301,13 @@ export default function ComposeScreen() { setContent(""); setSelectedChannels([]); setPostNow(false); - setImageUri(null); - setExistingMedia([]); + setMediaItems([]); setDraftBanner(false); setScheduleDate(new Date(Date.now() + 60 * 60 * 1000)); }; const formatDateLabel = (d: Date) => - d.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); + d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); const formatTimeLabel = (d: Date) => d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" }); @@ -306,447 +316,313 @@ export default function ComposeScreen() { return ( - - Not Configured - - - Add your API key in Settings - + Not Configured + Add your API key in Settings ); } return ( - - {draftBanner && ( - - - - You have a saved draft - - - Restore - - - - - - )} - - - - - {effectiveCharLimit < 3000 && selectedChannels.length > 0 && ( - - limit: {effectiveCharLimit} - - )} - = effectiveCharLimit - ? colors.error - : content.length > effectiveCharLimit * 0.9 - ? colors.warning - : colors.mutedForeground, - }, - ]} - > - {content.length}/{effectiveCharLimit} - - - - - {imageUri && ( - - - - - - - )} - - - - - {imageUri ? "Change image" : "Add image"} - - - - - CHANNELS - - - {loadingIntegrations ? ( - - ) : (integrations ?? []).length === 0 ? ( - - No channels found. Add integrations in your Postiz instance. - - ) : ( - - {(integrations ?? []).map((intg) => ( - toggleChannel(intg.id)} - /> - ))} - - )} - - - - - - Post now - - - - - - {!postNow && ( - - { - setShowTimePicker(false); - setShowDatePicker((v) => !v); - }} - style={[ - styles.dateBtn, - { backgroundColor: colors.card, borderColor: colors.border }, - ]} - activeOpacity={0.7} - > - - - {formatDateLabel(scheduleDate)} - - - { - setShowDatePicker(false); - setShowTimePicker((v) => !v); - }} - style={[ - styles.dateBtn, - { backgroundColor: colors.card, borderColor: colors.border }, - ]} - activeOpacity={0.7} - > - - - {formatTimeLabel(scheduleDate)} - - - - )} - - {showDatePicker && ( - { - if (Platform.OS !== "ios") setShowDatePicker(false); - if (date) { - const merged = new Date(scheduleDate); - merged.setFullYear(date.getFullYear(), date.getMonth(), date.getDate()); - setScheduleDate(merged); - } - }} - /> - )} - - {showTimePicker && ( - { - if (Platform.OS !== "ios") setShowTimePicker(false); - if (date) { - const merged = new Date(scheduleDate); - merged.setHours(date.getHours(), date.getMinutes()); - setScheduleDate(merged); - } - }} - /> - )} - - - - Save Draft - - - + - {submitting || uploading ? ( - - ) : ( - <> - - - {postNow ? "Publish Now" : "Schedule Post"} - - + {draftBanner && ( + + + You have a saved draft + + Restore + + setDraftBanner(false)} activeOpacity={0.7}> + + + )} - - + + + + + {effectiveCharLimit < 3000 && selectedChannels.length > 0 && ( + + limit: {effectiveCharLimit} + + )} + = effectiveCharLimit + ? colors.error + : content.length > effectiveCharLimit * 0.9 + ? colors.warning + : colors.mutedForeground, + }, + ]} + > + {content.length}/{effectiveCharLimit} + + + + + {mediaItems.length > 0 && ( + + {mediaItems.map((item, idx) => { + const uri = + item.type === "local" + ? item.uri + : resolveMediaUrl(item.path, baseUrl); + return ( + + + removeMediaItem(idx)} + style={[styles.removeImg, { backgroundColor: colors.destructive }]} + > + + + {item.type === "uploaded" && ( + + + + )} + + ); + })} + + )} + + {mediaItems.length < MAX_IMAGES && ( + + + + + {mediaItems.length === 0 ? "Add image" : "Add more"} + + + setShowMediaLibrary(true)} + activeOpacity={0.7} + style={[styles.mediaBtn, { backgroundColor: colors.card, borderColor: colors.border }]} + > + + Library + + + )} + + CHANNELS + + {loadingIntegrations ? ( + + ) : (integrations ?? []).length === 0 ? ( + + No channels found. Add integrations in your Postiz instance. + + ) : ( + + {(integrations ?? []).map((intg) => ( + toggleChannel(intg.id)} + /> + ))} + + )} + + + + + Post now + + + + + {!postNow && ( + + { setShowTimePicker(false); setShowDatePicker((v) => !v); }} + style={[styles.dateBtn, { backgroundColor: colors.card, borderColor: colors.border }]} + activeOpacity={0.7} + > + + + {formatDateLabel(scheduleDate)} + + + { setShowDatePicker(false); setShowTimePicker((v) => !v); }} + style={[styles.dateBtn, { backgroundColor: colors.card, borderColor: colors.border }]} + activeOpacity={0.7} + > + + + {formatTimeLabel(scheduleDate)} + + + + )} + + {showDatePicker && ( + { + if (Platform.OS !== "ios") setShowDatePicker(false); + if (date) { + const merged = new Date(scheduleDate); + merged.setFullYear(date.getFullYear(), date.getMonth(), date.getDate()); + setScheduleDate(merged); + } + }} + /> + )} + + {showTimePicker && ( + { + if (Platform.OS !== "ios") setShowTimePicker(false); + if (date) { + const merged = new Date(scheduleDate); + merged.setHours(date.getHours(), date.getMinutes()); + setScheduleDate(merged); + } + }} + /> + )} + + + + Save Draft + + + + {submitting || uploading ? ( + + ) : ( + <> + + + {postNow ? "Publish Now" : "Schedule Post"} + + + )} + + + + 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({ - container: { - paddingHorizontal: 16, - gap: 14, - }, - centered: { - flex: 1, - alignItems: "center", - justifyContent: "center", - gap: 10, - }, - textArea: { - borderRadius: 14, - borderWidth: 1, - padding: 14, - minHeight: 140, - }, - textInput: { - fontSize: 15, - fontFamily: "Inter_400Regular", - lineHeight: 22, - minHeight: 100, - }, - charCountRow: { - flexDirection: "row", - justifyContent: "flex-end", - alignItems: "center", - gap: 6, - marginTop: 4, - }, - charCountLabel: { - fontSize: 10, - fontFamily: "Inter_400Regular", - }, - 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", - }, + container: { paddingHorizontal: 16, gap: 14 }, + centered: { flex: 1, alignItems: "center", justifyContent: "center", gap: 10 }, + textArea: { borderRadius: 14, borderWidth: 1, padding: 14, minHeight: 140 }, + textInput: { fontSize: 15, fontFamily: "Inter_400Regular", lineHeight: 22, minHeight: 100 }, + charCountRow: { flexDirection: "row", justifyContent: "flex-end", alignItems: "center", gap: 6, marginTop: 4 }, + charCountLabel: { fontSize: 10, fontFamily: "Inter_400Regular" }, + 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" }, + imageRow: { gap: 10, paddingRight: 4 }, + imageThumbWrap: { position: "relative" }, + 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" }, + uploadedBadge: { position: "absolute", bottom: 4, left: 4, width: 16, height: 16, borderRadius: 8, alignItems: "center", justifyContent: "center" }, + mediaBtnsRow: { flexDirection: "row", gap: 8 }, + mediaBtn: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 14, paddingVertical: 10, borderRadius: 10, borderWidth: 1 }, + 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" }, }); diff --git a/artifacts/postiz-mobile/components/MediaLibraryModal.tsx b/artifacts/postiz-mobile/components/MediaLibraryModal.tsx new file mode 100644 index 0000000..3c49154 --- /dev/null +++ b/artifacts/postiz-mobile/components/MediaLibraryModal.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selected, setSelected] = useState>(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 ( + + + + + + + Media Library + 0 ? colors.primary : colors.muted }, + ]} + > + + {selected.size > 0 ? `Add ${selected.size}` : "Add"} + + + + + {loading ? ( + + + + ) : error ? ( + + + {error} + + Retry + + + ) : items.length === 0 ? ( + + + No media found + + ) : ( + 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 ( + toggle(item.id)} + activeOpacity={0.8} + style={styles.cell} + > + + {isSelected && ( + + + + + + )} + + ); + }} + /> + )} + + + ); +} + +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", + }, +}); diff --git a/artifacts/postiz-mobile/hooks/useNotifications.ts b/artifacts/postiz-mobile/hooks/useNotifications.ts index effe1e1..a91330b 100644 --- a/artifacts/postiz-mobile/hooks/useNotifications.ts +++ b/artifacts/postiz-mobile/hooks/useNotifications.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from "react"; import { Platform } from "react-native"; import { usePostiz } from "@/context/PostizContext"; import { PostizPost } from "@/context/PostizContext"; +import { stripHtml } from "@/lib/stripHtml"; const POLL_INTERVAL_MS = 15 * 60 * 1000; const SEEN_KEY = "postiz_seen_statuses"; @@ -44,10 +45,7 @@ async function sendStatusNotification(post: PostizPost) { await Notifications.scheduleNotificationAsync({ content: { title: isError ? "Post failed to publish" : "Post published!", - body: - post.content.length > 80 - ? post.content.slice(0, 80) + "…" - : post.content, + body: (() => { const t = stripHtml(post.content); return t.length > 80 ? t.slice(0, 80) + "…" : t; })(), data: { postId: post.id }, }, trigger: null,