Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0696f5663e | |||
| 4a531df8bd | |||
| 365f44dbe4 | |||
| 40c2ce20f3 | |||
| aa516667cd | |||
| f6fcf35cf8 |
@@ -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: |
|
||||||
|
|||||||
@@ -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",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user