Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0696f5663e | |||
| 4a531df8bd | |||
| 365f44dbe4 |
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export function stripHtml(html: string): string {
|
||||||
|
// Decode entities first so encoded tags like <p> are also stripped
|
||||||
|
let s = html
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/ /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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user