11 Commits

Author SHA1 Message Date
billisdead fb64b671d0 fix(mobile): replace 404 media library error with session-required state and device gallery fallback
Release APK / build (push) Has been cancelled
The Postiz public API v1 does not expose a media listing endpoint.
Switch URL to the correct internal path (/media?page=0&search=), handle
the resulting 401 with a dedicated lock-icon state, and wire the existing
device gallery picker as an onPickFromDevice fallback so the modal stays
usable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 19:08:15 +02:00
billisdead 0cf5800463 fix(mobile): restore images on repost, improve media library 404 error, null-safe stripHtml
Release APK / build (push) Has been cancelled
- Pass post.image[] as JSON prefillImages param when prefilling compose from an existing post (repost/edit/retry), replacing the broken single-image prefillImagePath/prefillImageId approach
- MediaLibraryModal now shows the attempted URL and a clear explanation on 404, making it easier to diagnose if the Postiz version does not expose GET /media
- stripHtml accepts null/undefined input and returns "" instead of throwing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 08:31:34 +02:00
billisdead 20ca6e0334 feat(compose): group channels by customer with tap-to-select-all
Release APK / build (push) Has been cancelled
Channels in the compose picker are now grouped by customer (as returned
by the Postiz API). Tapping a customer header selects or deselects all
its channels at once. Individual channel chips still toggle as before.
Workspace header is hidden when only one workspace is configured.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 21:54:49 +02:00
billisdead 9abd05d05a feat(compose): tap-to-select-all workspace + flat channel chips
Release APK / build (push) Has been cancelled
- Workspace header is now a TouchableOpacity: tap selects all its
  channels, re-tap deselects all (partial state shows minus-square icon)
- Removed sub-grouping by network type — channels are displayed as a
  flat chip row directly under each workspace card
- Removed unused networkLabel helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 18:46:35 +02:00
billisdead 59b688dafb feat: add dynamic changelog bullet points to GitHub release notes
Parse feat/fix commits since previous tag and render them as
'What's New' and 'Bug Fixes' sections in the release body.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 16:32:41 +02:00
billisdead 8b7a2eb644 feat: multi-workspace support + channels grouped by workspace and network
Release APK / build (push) Has been cancelled
- PostizContext: new PostizWorkspace type, multi-workspace storage
  (postiz_workspaces_v2), auto-migration from legacy single config,
  addWorkspace / updateWorkspace / removeWorkspace, clients map
- Settings: full rewrite with workspace card list (add / edit / delete)
- Compose: channels displayed in two levels — workspace section then
  network type (X/Twitter, Instagram, LinkedIn...) within each workspace;
  submit routes posts and image uploads per workspace
- MediaLibraryModal: workspace tabs when multiple workspaces configured,
  returned items carry their workspaceId

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 14:50:20 +02:00
billisdead d4c16ccf97 chore: translate release notes to English
Release APK / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:21:44 +02:00
billisdead 0696f5663e feat: multi-images, media library, + fix HTML in notifications
Release APK / build (push) Has been cancelled
Multi-images (compose):
- Replace single imageUri with mediaItems: MediaItem[] (local | uploaded)
- allowsMultipleSelection: true, selectionLimit up to 4 total
- Each picked image is resized to max 1920px before upload
- Thumbnail row with individual × remove buttons
- uploaded badge (cloud icon) on library/prefill images
- buildMediaPayload() uploads local items, passes uploaded items as-is

Media Library:
- New MediaLibraryModal component — full-screen modal
- Fetches GET /media from Postiz instance
- 3-column grid with multi-select (capped at remaining slots)
- Selected items added to compose media pool

Notifications:
- Strip HTML from notification body text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 17:09:08 +02:00
billisdead 4a531df8bd fix: strip HTML-encoded tags (decode entities before stripping)
Release APK / build (push) Has been cancelled
The previous stripHtml decoded &lt;/&gt; after the regex pass, so content
stored as &lt;p&gt;text&lt;/p&gt; was never stripped. Now entities are
decoded first, then all tags are removed.

Also strip HTML when prefilling compose from an existing post (Edit/Repost)
so the text field shows clean content, not raw markup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 17:01:15 +02:00
billisdead 365f44dbe4 feat: official Postiz icon + strip HTML from post content display
Release APK / build (push) Has been cancelled
- Replace icon.png with official Postiz logo (1024x1024, generated from
  upstream postiz.svg at gitroomhq/postiz-app)
- Add lib/stripHtml.ts: converts <br>/<p> to newlines, strips all tags,
  decodes HTML entities
- PostCard: use stripHtml on content before truncation and display
- posts.tsx: use stripHtml for context menu preview and clipboard copy
  (API payloads keep original HTML for retry/reschedule)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 16:08:43 +02:00
billisdead 40c2ce20f3 feat: resize images to max 1920px before upload
Release APK / build (push) Has been cancelled
Add expo-image-manipulator. In pickImage(), detect if image dimensions
exceed 1920px and resize (keeping aspect ratio) + compress to JPEG 0.85.
Previously only JPEG quality was set but dimensions were untouched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 15:58:12 +02:00
11 changed files with 1344 additions and 901 deletions
+26 -4
View File
@@ -99,6 +99,29 @@ jobs:
APK=$(ls artifacts/postiz-mobile/dist/*.apk | sort | tail -1) APK=$(ls artifacts/postiz-mobile/dist/*.apk | sort | tail -1)
echo "path=$APK" >> "$GITHUB_OUTPUT" echo "path=$APK" >> "$GITHUB_OUTPUT"
- name: Generate changelog
id: changelog
run: |
PREV_TAG=$(git tag --sort=-version:refname | grep -v "^${{ github.ref_name }}$" | head -1)
echo "Previous tag: $PREV_TAG"
FEATS=$(git log "${PREV_TAG}..HEAD" --pretty=format:"%s" --no-merges \
| grep -E "^feat(\([^)]+\))?: " \
| sed -E 's/^feat(\([^)]+\))?: //' \
| sed 's/^/- /')
FIXES=$(git log "${PREV_TAG}..HEAD" --pretty=format:"%s" --no-merges \
| grep -E "^fix(\([^)]+\))?: " \
| sed -E 's/^fix(\([^)]+\))?: //' \
| sed 's/^/- /')
{
echo "changelog<<CEOF"
[ -n "$FEATS" ] && printf "### What's New\n%s\n\n" "$FEATS"
[ -n "$FIXES" ] && printf "### Bug Fixes\n%s\n\n" "$FIXES"
echo "CEOF"
} >> "$GITHUB_OUTPUT"
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
@@ -106,11 +129,10 @@ jobs:
body: | body: |
## Postiz Mobile ${{ github.ref_name }} ## Postiz Mobile ${{ github.ref_name }}
APK signé pour Android — installation directe (sideload). ${{ steps.changelog.outputs.changelog }}
### Installation ### Installation
1. Activer "Sources inconnues" sur l'appareil 1. Enable "Unknown sources" on the device
2. Transférer l'APK et ouvrir pour installer 2. Transfer the APK to the device and open it to install
files: ${{ steps.apk.outputs.path }} files: ${{ steps.apk.outputs.path }}
draft: false draft: false
prerelease: ${{ contains(github.ref_name, '-') }} prerelease: ${{ contains(github.ref_name, '-') }}
+423 -298
View File
@@ -4,10 +4,11 @@ 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";
import React, { useEffect, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
@@ -23,9 +24,17 @@ 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 { PostizIntegration, PostizUploadResult, usePostiz } from "@/context/PostizContext"; import { LibraryMediaItem, MediaLibraryModal } from "@/components/MediaLibraryModal";
import {
PostizIntegration,
PostizUploadResult,
PostizWorkspace,
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,18 +45,36 @@ const NETWORK_CHAR_LIMITS: Record<string, number> = {
tiktok: 2200, tiktok: 2200,
}; };
// Integration enriched with its workspace info
type IntegrationWithWorkspace = PostizIntegration & {
workspaceId: string;
workspaceName: string;
workspace: PostizWorkspace;
};
type MediaItem =
| { type: "local"; uri: string }
| { type: "uploaded"; id: string; path: string; workspaceId: 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();
const { client, isConfigured, apiKey, baseUrl } = usePostiz(); const { workspaces, clients, isConfigured } = usePostiz();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId } = const { prefillContent, prefillIntegrationIds, prefillImages } =
useLocalSearchParams<{ useLocalSearchParams<{
prefillContent?: string; prefillContent?: string;
prefillIntegrationIds?: string; prefillIntegrationIds?: string;
prefillImagePath?: string; prefillImages?: 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,24 +83,34 @@ 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 (prefillImages && workspaces.length > 0) {
setExistingMedia([{ id: String(prefillImageId), path: String(prefillImagePath) }]); try {
setImageUri(String(prefillImagePath)); const images: Array<{ id: string; path: string }> = JSON.parse(String(prefillImages));
const wsId = workspaces[0]?.id ?? "";
setMediaItems(
images
.filter((img) => img?.id && img?.path)
.map((img): MediaItem => ({
type: "uploaded",
id: img.id,
path: img.path,
workspaceId: wsId,
}))
);
} catch {}
} }
}, [prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId]); }, [prefillContent, prefillIntegrationIds, prefillImages, workspaces]);
useEffect(() => { useEffect(() => {
if (prefillContent) return; if (prefillContent) return;
@@ -86,21 +123,90 @@ export default function ComposeScreen() {
}); });
}, [prefillContent]); }, [prefillContent]);
const { data: integrations, isLoading: loadingIntegrations } = // Fetch integrations from ALL workspaces in parallel
useQuery<PostizIntegration[]>({ const { data: allIntegrations, isLoading: loadingIntegrations } =
queryKey: ["integrations", !!client], useQuery<IntegrationWithWorkspace[]>({
queryKey: ["integrations-all", workspaces.map((w) => w.id).join(",")],
queryFn: async () => { queryFn: async () => {
const results = await Promise.all(
workspaces.map(async (ws) => {
const client = clients[ws.id];
if (!client) return []; if (!client) return [];
const res = await client.get("integrations"); const res = await client.get("integrations");
return Array.isArray(res.data) ? res.data : res.data?.integrations ?? []; const list: PostizIntegration[] = Array.isArray(res.data)
? res.data
: (res.data?.integrations ?? []);
return list.map((i): IntegrationWithWorkspace => ({
...i,
workspaceId: ws.id,
workspaceName: ws.name,
workspace: ws,
}));
})
);
return results.flat();
}, },
enabled: !!client, enabled: workspaces.length > 0 && Object.keys(clients).length > 0,
staleTime: 60000, staleTime: 60000,
}); });
const effectiveCharLimit = (() => { type CustomerGroup = {
if (selectedChannels.length === 0 || !integrations) return 3000; customerId: string;
const selected = integrations.filter((i) => selectedChannels.includes(i.id)); customerName: string;
channels: IntegrationWithWorkspace[];
};
type WorkspaceGroup = {
workspace: PostizWorkspace;
customers: CustomerGroup[];
allChannels: IntegrationWithWorkspace[];
};
// Group: workspace → customers → channels
const grouped = useMemo((): WorkspaceGroup[] => {
if (!allIntegrations) return [];
const byWorkspace = new Map<string, IntegrationWithWorkspace[]>();
for (const intg of allIntegrations) {
if (!byWorkspace.has(intg.workspaceId)) byWorkspace.set(intg.workspaceId, []);
byWorkspace.get(intg.workspaceId)!.push(intg);
}
return workspaces
.filter((ws) => byWorkspace.has(ws.id))
.map((ws) => {
const allChannels = byWorkspace.get(ws.id)!;
const byCustomer = new Map<string, CustomerGroup>();
for (const ch of allChannels) {
const cId = ch.customer?.id ?? "__default__";
const cName = ch.customer?.name ?? ws.name;
if (!byCustomer.has(cId)) byCustomer.set(cId, { customerId: cId, customerName: cName, channels: [] });
byCustomer.get(cId)!.channels.push(ch);
}
return { workspace: ws, customers: Array.from(byCustomer.values()), allChannels };
});
}, [allIntegrations, workspaces]);
const toggleWorkspace = (wsId: string) => {
const allIds = (grouped.find((g) => g.workspace.id === wsId)?.allChannels ?? []).map((c) => c.id);
const allSelected = allIds.every((id) => selectedChannels.includes(id));
if (allSelected) {
setSelectedChannels((prev) => prev.filter((id) => !allIds.includes(id)));
} else {
setSelectedChannels((prev) => [...new Set([...prev, ...allIds])]);
}
};
const toggleCustomer = (customerIds: string[]) => {
const allSelected = customerIds.every((id) => selectedChannels.includes(id));
if (allSelected) {
setSelectedChannels((prev) => prev.filter((id) => !customerIds.includes(id)));
} else {
setSelectedChannels((prev) => [...new Set([...prev, ...customerIds])]);
}
};
const effectiveCharLimit = useMemo(() => {
if (selectedChannels.length === 0 || !allIntegrations) return 3000;
const selected = allIntegrations.filter((i) => selectedChannels.includes(i.id));
const limits = selected.map((i) => { const limits = selected.map((i) => {
const t = (i.type ?? i.internalType ?? "").toLowerCase(); const t = (i.type ?? i.internalType ?? "").toLowerCase();
for (const [key, limit] of Object.entries(NETWORK_CHAR_LIMITS)) { for (const [key, limit] of Object.entries(NETWORK_CHAR_LIMITS)) {
@@ -109,7 +215,7 @@ export default function ComposeScreen() {
return 3000; return 3000;
}); });
return Math.min(...limits); return Math.min(...limits);
})(); }, [selectedChannels, allIntegrations]);
const saveDraft = async () => { const saveDraft = async () => {
const draft = { content, integrationIds: selectedChannels }; const draft = { content, integrationIds: selectedChannels };
@@ -129,8 +235,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,56 +242,84 @@ 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> => { // Upload local images to a specific workspace, returns { id, path }[]
setUploading(true); const uploadLocalToWorkspace = async (
try { localUris: string[],
ws: PostizWorkspace
): Promise<Array<{ id: string; path: string }>> => {
const result: Array<{ id: string; path: string }> = [];
for (const uri of localUris) {
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(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,
name: "upload.jpg", name: "upload.jpg",
type: "image/jpeg", type: "image/jpeg",
} as unknown as Blob); } as unknown as Blob);
} }
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
const uploadRes = await globalThis.fetch(`${baseUrl}/upload`, { const uploadRes = await globalThis.fetch(`${ws.baseUrl}/upload`, {
method: "POST", method: "POST",
headers: { Authorization: apiKey }, headers: { Authorization: ws.apiKey },
body: formData, body: formData,
}); });
if (!uploadRes.ok) { if (!uploadRes.ok) {
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(`[${ws.name}] Upload HTTP ${uploadRes.status}: ${raw.slice(0, 200)}`);
} }
return await uploadRes.json() as PostizUploadResult; const uploaded = (await uploadRes.json()) as PostizUploadResult;
} finally { result.push({ id: uploaded.id, path: uploaded.path });
setUploading(false);
} }
return result;
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -202,37 +334,55 @@ 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 }> = []; // Group selected channels by workspace
const isLocalFile = imageUri && !imageUri.startsWith("http"); const byWorkspace = new Map<string, { ws: PostizWorkspace; channelIds: string[] }>();
if (imageUri && isLocalFile) { for (const channelId of selectedChannels) {
const uploaded = await uploadImage(); const intg = allIntegrations?.find((i) => i.id === channelId);
media = [{ id: uploaded.id, path: uploaded.path }]; if (!intg) continue;
} else if (existingMedia.length > 0) { if (!byWorkspace.has(intg.workspaceId)) {
media = existingMedia; byWorkspace.set(intg.workspaceId, { ws: intg.workspace, channelIds: [] });
} }
byWorkspace.get(intg.workspaceId)!.channelIds.push(channelId);
}
const localUris = mediaItems.filter((m): m is MediaItem & { type: "local" } => m.type === "local").map((m) => m.uri);
const hasLocalImages = localUris.length > 0;
if (hasLocalImages) setUploading(true);
await Promise.all(
Array.from(byWorkspace.values()).map(async ({ ws, channelIds }) => {
// Already-uploaded media belonging to this workspace
const uploadedForWs = mediaItems
.filter((m): m is MediaItem & { type: "uploaded" } => m.type === "uploaded" && m.workspaceId === ws.id)
.map(({ id, path }) => ({ id, path }));
// Upload local images to this workspace
const localUploaded = hasLocalImages
? await uploadLocalToWorkspace(localUris, ws)
: [];
const media = [...uploadedForWs, ...localUploaded];
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: channelIds.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", `${ws.baseUrl}/posts`, body);
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
const res = await globalThis.fetch(`${baseUrl}/posts`, { const res = await globalThis.fetch(`${ws.baseUrl}/posts`, {
method: "POST", method: "POST",
headers: { headers: { Authorization: ws.apiKey, "Content-Type": "application/json" },
Authorization: apiKey,
"Content-Type": "application/json",
},
body, body,
}); });
@@ -240,14 +390,15 @@ 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][${ws.name}] error body:`, raw);
detail = raw.slice(0, 500); detail = raw.slice(0, 500);
} catch { } catch { detail = res.statusText; }
detail = res.statusText; throw new Error(`[${ws.name}] HTTP ${res.status}: ${detail}`);
}
throw new Error(`HTTP ${res.status}: ${detail}`);
} }
})
);
if (hasLocalImages) setUploading(false);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
await AsyncStorage.removeItem(DRAFT_STORAGE_KEY); await AsyncStorage.removeItem(DRAFT_STORAGE_KEY);
Alert.alert( Alert.alert(
@@ -258,6 +409,7 @@ export default function ComposeScreen() {
queryClient.invalidateQueries({ queryKey: ["posts"] }); queryClient.invalidateQueries({ queryKey: ["posts"] });
queryClient.invalidateQueries({ queryKey: ["posts-list"] }); queryClient.invalidateQueries({ queryKey: ["posts-list"] });
} catch (e: unknown) { } catch (e: unknown) {
setUploading(false);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
const msg = e instanceof Error ? e.message : "Could not submit post."; const msg = e instanceof Error ? e.message : "Could not submit post.";
Alert.alert("Failed", msg); Alert.alert("Failed", msg);
@@ -270,18 +422,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 +437,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 a workspace 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 +461,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,53 +506,138 @@ 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, workspaces.find((w) => w.id === item.workspaceId)?.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 }]}> {/* Channels grouped by workspace then network type */}
CHANNELS <Text style={[styles.sectionLabel, { color: colors.mutedForeground }]}>CHANNELS</Text>
</Text>
{loadingIntegrations ? ( {loadingIntegrations ? (
<ActivityIndicator color={colors.primary} style={{ marginVertical: 8 }} /> <ActivityIndicator color={colors.primary} style={{ marginVertical: 8 }} />
) : (integrations ?? []).length === 0 ? ( ) : grouped.length === 0 ? (
<Text style={[styles.hint, { color: colors.mutedForeground }]}> <Text style={[styles.hint, { color: colors.mutedForeground }]}>
No channels found. Add integrations in your Postiz instance. No channels found. Add integrations in your Postiz instance.
</Text> </Text>
) : ( ) : (
<ScrollView <View style={styles.channelGroups}>
horizontal {grouped.map(({ workspace, customers, allChannels }, wsIdx) => {
showsHorizontalScrollIndicator={false} const wsAllIds = allChannels.map((c) => c.id);
contentContainerStyle={styles.channelList} const wsSelectedCount = wsAllIds.filter((id) => selectedChannels.includes(id)).length;
const wsAllSelected = wsSelectedCount === wsAllIds.length;
const wsSomeSelected = wsSelectedCount > 0 && !wsAllSelected;
return (
<View
key={workspace.id}
style={[
styles.workspaceSection,
{ backgroundColor: colors.card, borderColor: colors.border },
wsIdx > 0 && { marginTop: 8 },
]}
> >
{(integrations ?? []).map((intg) => ( {/* Workspace header — tap to select/deselect all */}
{workspaces.length > 1 && (
<TouchableOpacity
activeOpacity={0.7}
onPress={() => toggleWorkspace(workspace.id)}
style={[styles.workspaceHeader, { borderBottomColor: colors.border }]}
>
<Feather name="briefcase" size={12} color={colors.primary} />
<Text style={[styles.workspaceName, { color: colors.primary, flex: 1 }]}>
{workspace.name}
</Text>
<Feather
name={wsAllSelected ? "check-square" : wsSomeSelected ? "minus-square" : "square"}
size={14}
color={wsAllSelected || wsSomeSelected ? colors.primary : colors.mutedForeground}
/>
</TouchableOpacity>
)}
{/* Customer sub-sections */}
{customers.map((cust, cIdx) => {
const custIds = cust.channels.map((c) => c.id);
const custSelectedCount = custIds.filter((id) => selectedChannels.includes(id)).length;
const custAllSelected = custSelectedCount === custIds.length;
const custSomeSelected = custSelectedCount > 0 && !custAllSelected;
return (
<View
key={cust.customerId}
style={cIdx > 0 ? [styles.customerSection, { borderTopColor: colors.border }] : undefined}
>
{/* Customer header — tap to select/deselect all channels for this customer */}
<TouchableOpacity
activeOpacity={0.7}
onPress={() => toggleCustomer(custIds)}
style={styles.customerHeader}
>
<Text style={[styles.customerName, { color: colors.foreground }]}>
{cust.customerName}
</Text>
<Feather
name={custAllSelected ? "check-square" : custSomeSelected ? "minus-square" : "square"}
size={14}
color={custAllSelected || custSomeSelected ? colors.primary : colors.mutedForeground}
/>
</TouchableOpacity>
{/* Channel chips for this customer */}
<View style={styles.chipRow}>
{cust.channels.map((intg) => (
<ChannelChip <ChannelChip
key={intg.id} key={intg.id}
integration={intg} integration={intg}
@@ -423,20 +645,20 @@ export default function ComposeScreen() {
onToggle={() => toggleChannel(intg.id)} onToggle={() => toggleChannel(intg.id)}
/> />
))} ))}
</ScrollView> </View>
</View>
);
})}
</View>
);
})}
</View>
)} )}
<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 +671,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 +681,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 +746,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 +761,101 @@ export default function ComposeScreen() {
)} )}
</TouchableOpacity> </TouchableOpacity>
</KeyboardAwareScrollView> </KeyboardAwareScrollView>
<MediaLibraryModal
visible={showMediaLibrary}
workspaces={workspaces}
maxSelect={MAX_IMAGES - mediaItems.length}
onClose={() => setShowMediaLibrary(false)}
onPickFromDevice={() => { setShowMediaLibrary(false); pickImage(); }}
onSelect={(items: LibraryMediaItem[]) => {
setMediaItems((prev) =>
[
...prev,
...items.map((i): MediaItem => ({
type: "uploaded",
id: i.id,
path: i.path,
workspaceId: i.workspaceId,
})),
].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",
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: { draftBanner: {
flexDirection: "row", flexDirection: "row", alignItems: "center", gap: 8,
alignItems: "center", paddingHorizontal: 14, paddingVertical: 10, borderRadius: 12, borderWidth: 1,
gap: 8,
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 12,
borderWidth: 1,
},
draftBannerText: {
flex: 1,
fontSize: 13,
fontFamily: "Inter_400Regular",
},
draftBannerAction: {
fontSize: 13,
fontFamily: "Inter_600SemiBold",
}, },
draftBannerText: { flex: 1, fontSize: 13, fontFamily: "Inter_400Regular" },
draftBannerAction: { fontSize: 13, fontFamily: "Inter_600SemiBold" },
draftBtn: { draftBtn: {
flexDirection: "row", flexDirection: "row", alignItems: "center", justifyContent: "center",
alignItems: "center", gap: 6, paddingVertical: 10, borderRadius: 12, borderWidth: 1,
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,
}, },
draftBtnText: { fontSize: 13, fontFamily: "Inter_500Medium" },
imageRow: { gap: 10, paddingRight: 4 },
imageThumbWrap: { position: "relative" },
imageThumb: { width: 100, height: 100, borderRadius: 10, borderWidth: 1 },
removeImg: { removeImg: {
position: "absolute", position: "absolute", top: 4, right: 4,
top: 4, width: 20, height: 20, borderRadius: 10, alignItems: "center", justifyContent: "center",
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: { mediaBtn: {
flexDirection: "row", flexDirection: "row", alignItems: "center", gap: 8,
alignItems: "center", paddingHorizontal: 14, paddingVertical: 10, borderRadius: 10, borderWidth: 1,
gap: 8,
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 10,
borderWidth: 1,
alignSelf: "flex-start",
}, },
mediaBtnText: { mediaBtnText: { fontSize: 13, fontFamily: "Inter_500Medium" },
fontSize: 13, sectionLabel: { fontSize: 11, fontFamily: "Inter_600SemiBold", letterSpacing: 0.8, marginBottom: -6 },
fontFamily: "Inter_500Medium", sectionTitle: { fontSize: 18, fontFamily: "Inter_600SemiBold" },
hint: { fontSize: 13, fontFamily: "Inter_400Regular", textAlign: "center" },
channelGroups: { gap: 0 },
workspaceSection: { borderRadius: 14, borderWidth: 1, overflow: "hidden" },
workspaceHeader: {
flexDirection: "row", alignItems: "center", gap: 6,
paddingHorizontal: 12, paddingVertical: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
}, },
sectionLabel: { workspaceName: { fontSize: 11, fontFamily: "Inter_600SemiBold", letterSpacing: 0.5 },
fontSize: 11, customerSection: { borderTopWidth: StyleSheet.hairlineWidth },
fontFamily: "Inter_600SemiBold", customerHeader: {
letterSpacing: 0.8, flexDirection: "row", alignItems: "center", justifyContent: "space-between",
marginBottom: -6, paddingHorizontal: 12, paddingVertical: 8,
},
sectionTitle: {
fontSize: 18,
fontFamily: "Inter_600SemiBold",
},
hint: {
fontSize: 13,
fontFamily: "Inter_400Regular",
textAlign: "center",
},
channelList: {
flexDirection: "row",
gap: 8,
flexWrap: "wrap",
}, },
customerName: { fontSize: 13, fontFamily: "Inter_600SemiBold" },
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 6, paddingHorizontal: 10, paddingBottom: 10 },
scheduleRow: { scheduleRow: {
flexDirection: "row", flexDirection: "row", alignItems: "center", justifyContent: "space-between",
alignItems: "center", paddingHorizontal: 16, paddingVertical: 12, borderRadius: 12, borderWidth: 1,
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,
}, },
scheduleRowLeft: { flexDirection: "row", alignItems: "center", gap: 10 },
scheduleLabel: { fontSize: 15, fontFamily: "Inter_500Medium" },
dateTimeRow: { flexDirection: "row", gap: 10 },
dateBtn: { dateBtn: {
flex: 1, flex: 1, flexDirection: "row", alignItems: "center", gap: 8,
flexDirection: "row", paddingHorizontal: 12, paddingVertical: 10, borderRadius: 10, borderWidth: 1,
alignItems: "center",
gap: 8,
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 10,
borderWidth: 1,
},
dateBtnText: {
fontSize: 13,
fontFamily: "Inter_500Medium",
}, },
dateBtnText: { fontSize: 13, fontFamily: "Inter_500Medium" },
submitBtn: { submitBtn: {
flexDirection: "row", flexDirection: "row", alignItems: "center", justifyContent: "center",
alignItems: "center", gap: 8, paddingVertical: 14, borderRadius: 14, marginTop: 4,
justifyContent: "center",
gap: 8,
paddingVertical: 14,
borderRadius: 14,
marginTop: 4,
},
submitText: {
fontSize: 15,
fontFamily: "Inter_600SemiBold",
}, },
submitText: { fontSize: 15, fontFamily: "Inter_600SemiBold" },
}); });
+11 -8
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";
@@ -151,13 +152,14 @@ export default function PostsScreen() {
const handlePrefillCompose = (post: PostizPost) => { const handlePrefillCompose = (post: PostizPost) => {
const integrations = post.integrations ?? (post.integration ? [post.integration] : []); const integrations = post.integrations ?? (post.integration ? [post.integration] : []);
router.push({ const params: Record<string, string> = {
pathname: "/(tabs)/compose", prefillContent: stripHtml(post.content),
params: {
prefillContent: post.content,
prefillIntegrationIds: integrations.map((i) => i.id).join(","), prefillIntegrationIds: integrations.map((i) => i.id).join(","),
}, };
}); if (post.image?.length) {
params.prefillImages = JSON.stringify(post.image);
}
router.push({ pathname: "/(tabs)/compose", params });
}; };
const startReschedule = (post: PostizPost) => { const startReschedule = (post: PostizPost) => {
@@ -193,14 +195,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);
+211 -220
View File
@@ -1,7 +1,7 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import axios from "axios"; import axios from "axios";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
import React, { useEffect, useState } from "react"; import React, { useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
@@ -15,49 +15,73 @@ import {
} from "react-native"; } from "react-native";
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 { usePostiz, DEFAULT_BASE_URL } from "@/context/PostizContext"; import { PostizWorkspace, DEFAULT_BASE_URL, usePostiz } from "@/context/PostizContext";
import { useColors } from "@/hooks/useColors"; import { useColors } from "@/hooks/useColors";
import { extractError } from "@/lib/extractError"; import { extractError } from "@/lib/extractError";
type FormState = {
id?: string;
name: string;
url: string;
key: string;
};
const EMPTY_FORM: FormState = { name: "", url: DEFAULT_BASE_URL, key: "" };
export default function SettingsScreen() { export default function SettingsScreen() {
const colors = useColors(); const colors = useColors();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { apiKey, baseUrl, isConfigured, saveSettings, clearSettings } = usePostiz(); const { workspaces, isConfigured, addWorkspace, updateWorkspace, removeWorkspace } = usePostiz();
const [inputKey, setInputKey] = useState(apiKey); const [form, setForm] = useState<FormState | null>(null);
const [inputUrl, setInputUrl] = useState(baseUrl || DEFAULT_BASE_URL);
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
const [validating, setValidating] = useState(false); const [validating, setValidating] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [validationStatus, setValidationStatus] = useState<"idle" | "ok" | "error">("idle"); const [validationStatus, setValidationStatus] = useState<"idle" | "ok" | "error">("idle");
const [errorDetail, setErrorDetail] = useState<string>(""); const [errorDetail, setErrorDetail] = useState("");
useEffect(() => { const openAdd = () => {
setInputKey(apiKey); setForm(EMPTY_FORM);
setInputUrl(baseUrl || DEFAULT_BASE_URL); setShowKey(false);
}, [apiKey, baseUrl]); resetValidation();
};
const openEdit = (ws: PostizWorkspace) => {
setForm({ id: ws.id, name: ws.name, url: ws.baseUrl, key: ws.apiKey });
setShowKey(false);
resetValidation();
};
const closeForm = () => {
setForm(null);
resetValidation();
};
const resetValidation = () => {
setValidationStatus("idle");
setErrorDetail("");
};
const patchForm = (patch: Partial<FormState>) => {
setForm((prev) => (prev ? { ...prev, ...patch } : prev));
resetValidation();
};
const handleValidate = async () => { const handleValidate = async () => {
if (!inputKey.trim() || !inputUrl.trim()) { if (!form?.key.trim() || !form?.url.trim()) {
Alert.alert("Missing fields", "Please enter both API key and base URL."); Alert.alert("Missing fields", "Please enter both API key and base URL.");
return; return;
} }
setValidating(true); setValidating(true);
setValidationStatus("idle"); resetValidation();
setErrorDetail(""); const cleanUrl = form.url.trim().replace(/\/$/, "");
const cleanUrl = inputUrl.trim().replace(/\/$/, ""); const variants = [form.key.trim(), `Bearer ${form.key.trim()}`];
let lastError = "";
const authVariants = [ for (const auth of variants) {
inputKey.trim(),
`Bearer ${inputKey.trim()}`,
];
let lastError: string = "";
for (const authHeader of authVariants) {
try { try {
await axios.get(`${cleanUrl}/integrations`, { await axios.get(`${cleanUrl}/integrations`, {
headers: { Authorization: authHeader }, headers: { Authorization: auth },
timeout: 10000, timeout: 10000,
maxRedirects: 0, maxRedirects: 0,
}); });
@@ -67,21 +91,17 @@ export default function SettingsScreen() {
return; return;
} catch (err: unknown) { } catch (err: unknown) {
if (axios.isAxiosError(err)) { if (axios.isAxiosError(err)) {
const status = err.response?.status; const s = err.response?.status;
if (status === 307 || status === 301 || status === 302 || status === 308) { if (s === 307 || s === 301 || s === 302 || s === 308) {
const location = err.response?.headers?.location ?? "unknown"; const loc = err.response?.headers?.location ?? "unknown";
lastError = `HTTP ${status} redirect → ${location}. The API rejected the request and redirected to login. Check the Authorization header format or the base URL.`; lastError = `HTTP ${s} redirect → ${loc}. Check the Authorization format or base URL.`;
continue;
}
if (status === 401 || status === 403) {
lastError = `HTTP ${status}: Invalid or expired API key.`;
continue; continue;
} }
if (s === 401 || s === 403) { lastError = `HTTP ${s}: Invalid or expired API key.`; continue; }
} }
lastError = extractError(err); lastError = extractError(err);
} }
} }
setErrorDetail(lastError); setErrorDetail(lastError);
setValidationStatus("error"); setValidationStatus("error");
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
@@ -89,37 +109,38 @@ export default function SettingsScreen() {
}; };
const handleSave = async () => { const handleSave = async () => {
if (!inputKey.trim() || !inputUrl.trim()) { if (!form) return;
Alert.alert("Missing fields", "Please enter both API key and base URL."); if (!form.name.trim()) { Alert.alert("Missing name", "Please enter a name for this workspace."); return; }
return; if (!form.key.trim() || !form.url.trim()) { Alert.alert("Missing fields", "Please enter both API key and base URL."); return; }
}
setSaving(true); setSaving(true);
try { try {
await saveSettings(inputKey.trim(), inputUrl.trim().replace(/\/$/, "")); const ws = { name: form.name.trim(), apiKey: form.key.trim(), baseUrl: form.url.trim().replace(/\/$/, "") };
if (form.id) {
await updateWorkspace({ ...ws, id: form.id });
} else {
await addWorkspace(ws);
}
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
Alert.alert("Saved", "Settings saved successfully."); closeForm();
} catch (err: unknown) { } catch (err: unknown) {
Alert.alert("Error", `Failed to save settings.\n${extractError(err)}`); Alert.alert("Error", `Failed to save.\n${extractError(err)}`);
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
const handleClear = () => { const handleDelete = (ws: PostizWorkspace) => {
Alert.alert( Alert.alert(
"Disconnect", "Remove workspace",
"Remove your API key and disconnect from Postiz?", `Remove "${ws.name}"? Channels from this workspace will no longer be available.`,
[ [
{ text: "Cancel", style: "cancel" }, { text: "Cancel", style: "cancel" },
{ {
text: "Disconnect", text: "Remove",
style: "destructive", style: "destructive",
onPress: async () => { onPress: async () => {
await clearSettings(); if (form?.id === ws.id) closeForm();
setInputKey(""); await removeWorkspace(ws.id);
setInputUrl(DEFAULT_BASE_URL);
setValidationStatus("idle");
setErrorDetail("");
}, },
}, },
] ]
@@ -140,34 +161,96 @@ export default function SettingsScreen() {
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{!isConfigured && ( {/* Status banner */}
{!isConfigured ? (
<View style={[styles.banner, { backgroundColor: colors.primary + "18", borderColor: colors.primary + "40" }]}> <View style={[styles.banner, { backgroundColor: colors.primary + "18", borderColor: colors.primary + "40" }]}>
<Feather name="info" size={16} color={colors.primary} /> <Feather name="info" size={16} color={colors.primary} />
<Text style={[styles.bannerText, { color: colors.primary }]}> <Text style={[styles.bannerText, { color: colors.primary }]}>
Connect to your Postiz instance to get started Add a workspace to get started
</Text> </Text>
</View> </View>
)} ) : (
{isConfigured && (
<View style={[styles.connectedBadge, { backgroundColor: colors.success + "18", borderColor: colors.success + "40" }]}> <View style={[styles.connectedBadge, { backgroundColor: colors.success + "18", borderColor: colors.success + "40" }]}>
<Feather name="check-circle" size={14} color={colors.success} /> <Feather name="check-circle" size={14} color={colors.success} />
<Text style={[styles.connectedText, { color: colors.success }]}> <Text style={[styles.connectedText, { color: colors.success }]}>
Connected to Postiz {workspaces.length} workspace{workspaces.length > 1 ? "s" : ""} configured
</Text> </Text>
</View> </View>
)} )}
<View style={styles.section}> {/* Workspace cards */}
{workspaces.map((ws) => (
<View key={ws.id} style={[styles.wsCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
<View style={styles.wsCardHeader}>
<View style={styles.wsCardLeft}>
<View style={[styles.wsIcon, { backgroundColor: colors.primary + "18" }]}>
<Feather name="briefcase" size={14} color={colors.primary} />
</View>
<View>
<Text style={[styles.wsName, { color: colors.foreground }]}>{ws.name}</Text>
<Text style={[styles.wsUrl, { color: colors.mutedForeground }]} numberOfLines={1}>
{ws.baseUrl.replace(/^https?:\/\//, "").replace(/\/api.*$/, "")}
</Text>
</View>
</View>
<View style={styles.wsCardActions}>
<TouchableOpacity onPress={() => openEdit(ws)} activeOpacity={0.7} style={styles.iconBtn}>
<Feather name="edit-2" size={15} color={colors.mutedForeground} />
</TouchableOpacity>
<TouchableOpacity onPress={() => handleDelete(ws)} activeOpacity={0.7} style={styles.iconBtn}>
<Feather name="trash-2" size={15} color={colors.destructive} />
</TouchableOpacity>
</View>
</View>
</View>
))}
{/* Add workspace button */}
{!form && (
<TouchableOpacity
onPress={openAdd}
activeOpacity={0.8}
style={[styles.addBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
>
<Feather name="plus" size={16} color={colors.primary} />
<Text style={[styles.addBtnText, { color: colors.primary }]}>Add workspace</Text>
</TouchableOpacity>
)}
{/* Add / Edit form */}
{form && (
<View style={[styles.formCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
<Text style={[styles.formTitle, { color: colors.foreground }]}>
{form.id ? "Edit workspace" : "Add workspace"}
</Text>
{/* Name */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>NAME</Text>
<View style={[styles.inputWrap, { backgroundColor: colors.background, borderColor: colors.border }]}>
<Feather name="briefcase" size={15} color={colors.mutedForeground} />
<TextInput
style={[styles.input, { color: colors.foreground }]}
placeholder="My Client"
placeholderTextColor={colors.mutedForeground}
value={form.name}
onChangeText={(t) => patchForm({ name: t })}
autoCorrect={false}
/>
</View>
</View>
{/* Base URL */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>BASE URL</Text> <Text style={[styles.label, { color: colors.mutedForeground }]}>BASE URL</Text>
<View style={[styles.inputWrap, { backgroundColor: colors.card, borderColor: colors.border }]}> <View style={[styles.inputWrap, { backgroundColor: colors.background, borderColor: colors.border }]}>
<Feather name="globe" size={16} color={colors.mutedForeground} style={styles.inputIcon} /> <Feather name="globe" size={15} color={colors.mutedForeground} />
<TextInput <TextInput
style={[styles.input, { color: colors.foreground }]} style={[styles.input, { color: colors.foreground }]}
placeholder="https://postiz.example.com/api/public/v1" placeholder="https://postiz.example.com/api/public/v1"
placeholderTextColor={colors.mutedForeground} placeholderTextColor={colors.mutedForeground}
value={inputUrl} value={form.url}
onChangeText={(t) => { setInputUrl(t); setValidationStatus("idle"); setErrorDetail(""); }} onChangeText={(t) => patchForm({ url: t })}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
keyboardType="url" keyboardType="url"
@@ -175,31 +258,30 @@ export default function SettingsScreen() {
</View> </View>
</View> </View>
<View style={styles.section}> {/* API Key */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>API KEY</Text> <Text style={[styles.label, { color: colors.mutedForeground }]}>API KEY</Text>
<View style={[styles.inputWrap, { backgroundColor: colors.card, borderColor: colors.border }]}> <View style={[styles.inputWrap, { backgroundColor: colors.background, borderColor: colors.border }]}>
<Feather name="key" size={16} color={colors.mutedForeground} style={styles.inputIcon} /> <Feather name="key" size={15} color={colors.mutedForeground} />
<TextInput <TextInput
style={[styles.input, { color: colors.foreground }]} style={[styles.input, { color: colors.foreground }]}
placeholder="Enter your API key" placeholder="Enter your API key"
placeholderTextColor={colors.mutedForeground} placeholderTextColor={colors.mutedForeground}
value={inputKey} value={form.key}
onChangeText={(t) => { setInputKey(t); setValidationStatus("idle"); setErrorDetail(""); }} onChangeText={(t) => patchForm({ key: t })}
secureTextEntry={!showKey} secureTextEntry={!showKey}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
/> />
<TouchableOpacity onPress={() => setShowKey((v) => !v)} activeOpacity={0.7}> <TouchableOpacity onPress={() => setShowKey((v) => !v)} activeOpacity={0.7}>
<Feather name={showKey ? "eye-off" : "eye"} size={16} color={colors.mutedForeground} /> <Feather name={showKey ? "eye-off" : "eye"} size={15} color={colors.mutedForeground} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{validationStatus === "ok" && ( {validationStatus === "ok" && (
<View style={styles.validationRow}> <View style={styles.validRow}>
<Feather name="check-circle" size={13} color={colors.success} /> <Feather name="check-circle" size={13} color={colors.success} />
<Text style={[styles.validationText, { color: colors.success }]}> <Text style={[styles.validText, { color: colors.success }]}>Connection successful</Text>
Connection successful
</Text>
</View> </View>
)} )}
@@ -207,39 +289,42 @@ export default function SettingsScreen() {
<View style={[styles.errorBox, { backgroundColor: colors.error + "12", borderColor: colors.error + "30" }]}> <View style={[styles.errorBox, { backgroundColor: colors.error + "12", borderColor: colors.error + "30" }]}>
<View style={styles.errorHeader}> <View style={styles.errorHeader}>
<Feather name="x-circle" size={13} color={colors.error} /> <Feather name="x-circle" size={13} color={colors.error} />
<Text style={[styles.errorTitle, { color: colors.error }]}> <Text style={[styles.errorTitle, { color: colors.error }]}>Could not connect</Text>
Could not connect
</Text>
</View> </View>
{!!errorDetail && ( {!!errorDetail && (
<ScrollView style={styles.errorScroll} nestedScrollEnabled> <ScrollView style={styles.errorScroll} nestedScrollEnabled>
<Text style={[styles.errorDetail, { color: colors.error }]} selectable> <Text style={[styles.errorDetail, { color: colors.error }]} selectable>{errorDetail}</Text>
{errorDetail}
</Text>
</ScrollView> </ScrollView>
)} )}
</View> </View>
)} )}
</View> </View>
{/* Form actions */}
<TouchableOpacity <TouchableOpacity
onPress={handleValidate} onPress={handleValidate}
activeOpacity={0.8} activeOpacity={0.8}
disabled={validating} disabled={validating}
style={[styles.validateBtn, { backgroundColor: colors.card, borderColor: colors.border }]} style={[styles.validateBtn, { backgroundColor: colors.background, borderColor: colors.border }]}
> >
{validating ? ( {validating ? (
<ActivityIndicator color={colors.primary} size="small" /> <ActivityIndicator color={colors.primary} size="small" />
) : ( ) : (
<> <>
<Feather name="wifi" size={15} color={colors.primary} /> <Feather name="wifi" size={15} color={colors.primary} />
<Text style={[styles.validateText, { color: colors.primary }]}> <Text style={[styles.validateText, { color: colors.primary }]}>Test Connection</Text>
Test Connection
</Text>
</> </>
)} )}
</TouchableOpacity> </TouchableOpacity>
<View style={styles.formBtnsRow}>
<TouchableOpacity
onPress={closeForm}
activeOpacity={0.8}
style={[styles.cancelBtn, { borderColor: colors.border }]}
>
<Text style={[styles.cancelText, { color: colors.mutedForeground }]}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
onPress={handleSave} onPress={handleSave}
activeOpacity={0.85} activeOpacity={0.85}
@@ -252,169 +337,75 @@ export default function SettingsScreen() {
<> <>
<Feather name="save" size={15} color={colors.primaryForeground} /> <Feather name="save" size={15} color={colors.primaryForeground} />
<Text style={[styles.saveText, { color: colors.primaryForeground }]}> <Text style={[styles.saveText, { color: colors.primaryForeground }]}>
Save Settings {form.id ? "Update" : "Save"}
</Text> </Text>
</> </>
)} )}
</TouchableOpacity> </TouchableOpacity>
</View>
{isConfigured && ( </View>
<TouchableOpacity
onPress={handleClear}
activeOpacity={0.8}
style={[styles.clearBtn, { borderColor: colors.destructive + "60" }]}
>
<Feather name="log-out" size={14} color={colors.destructive} />
<Text style={[styles.clearText, { color: colors.destructive }]}>Disconnect</Text>
</TouchableOpacity>
)} )}
<View style={styles.footer}>
<Text style={[styles.footerText, { color: colors.mutedForeground }]}> <Text style={[styles.footerText, { color: colors.mutedForeground }]}>
Your API key is stored securely on this device and never transmitted to third parties. API keys are stored securely on this device and never transmitted to third parties.
</Text> </Text>
</View>
</KeyboardAwareScrollView> </KeyboardAwareScrollView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: { paddingHorizontal: 20, gap: 14 },
paddingHorizontal: 20,
gap: 16,
},
banner: { banner: {
flexDirection: "row", flexDirection: "row", alignItems: "center", gap: 10,
alignItems: "center", paddingHorizontal: 14, paddingVertical: 12, borderRadius: 12, borderWidth: 1,
gap: 10,
paddingHorizontal: 14,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
},
bannerText: {
fontSize: 13,
fontFamily: "Inter_500Medium",
flex: 1,
}, },
bannerText: { fontSize: 13, fontFamily: "Inter_500Medium", flex: 1 },
connectedBadge: { connectedBadge: {
flexDirection: "row", flexDirection: "row", alignItems: "center", gap: 6,
alignItems: "center", paddingHorizontal: 12, paddingVertical: 8, borderRadius: 10, borderWidth: 1, alignSelf: "flex-start",
gap: 6,
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 10,
borderWidth: 1,
alignSelf: "flex-start",
}, },
connectedText: { connectedText: { fontSize: 12, fontFamily: "Inter_600SemiBold" },
fontSize: 12, wsCard: { borderRadius: 14, borderWidth: 1, overflow: "hidden" },
fontFamily: "Inter_600SemiBold", wsCardHeader: { flexDirection: "row", alignItems: "center", paddingHorizontal: 14, paddingVertical: 12 },
}, wsCardLeft: { flex: 1, flexDirection: "row", alignItems: "center", gap: 12 },
section: { wsIcon: { width: 32, height: 32, borderRadius: 10, alignItems: "center", justifyContent: "center" },
gap: 8, wsName: { fontSize: 14, fontFamily: "Inter_600SemiBold" },
}, wsUrl: { fontSize: 11, fontFamily: "Inter_400Regular", marginTop: 1 },
label: { wsCardActions: { flexDirection: "row", gap: 4 },
fontSize: 11, iconBtn: { padding: 8 },
fontFamily: "Inter_600SemiBold", addBtn: {
letterSpacing: 0.8, flexDirection: "row", alignItems: "center", justifyContent: "center",
marginLeft: 2, gap: 8, paddingVertical: 13, borderRadius: 14, borderWidth: 1, borderStyle: "dashed",
}, },
addBtnText: { fontSize: 14, fontFamily: "Inter_600SemiBold" },
formCard: { borderRadius: 14, borderWidth: 1, padding: 16, gap: 14 },
formTitle: { fontSize: 15, fontFamily: "Inter_600SemiBold" },
fieldGroup: { gap: 6 },
label: { fontSize: 11, fontFamily: "Inter_600SemiBold", letterSpacing: 0.8, marginLeft: 2 },
inputWrap: { inputWrap: {
flexDirection: "row", flexDirection: "row", alignItems: "center",
alignItems: "center", borderRadius: 10, borderWidth: 1, paddingHorizontal: 12, paddingVertical: 11, gap: 10,
borderRadius: 12,
borderWidth: 1,
paddingHorizontal: 14,
paddingVertical: 12,
gap: 10,
},
inputIcon: {
flexShrink: 0,
},
input: {
flex: 1,
fontSize: 14,
fontFamily: "Inter_400Regular",
},
validationRow: {
flexDirection: "row",
alignItems: "center",
gap: 6,
marginLeft: 2,
},
validationText: {
fontSize: 12,
fontFamily: "Inter_400Regular",
},
errorBox: {
borderRadius: 10,
borderWidth: 1,
padding: 12,
gap: 6,
},
errorHeader: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
errorTitle: {
fontSize: 12,
fontFamily: "Inter_600SemiBold",
},
errorScroll: {
maxHeight: 80,
},
errorDetail: {
fontSize: 11,
fontFamily: "Inter_400Regular",
lineHeight: 16,
}, },
input: { flex: 1, fontSize: 14, fontFamily: "Inter_400Regular" },
validRow: { flexDirection: "row", alignItems: "center", gap: 6, marginLeft: 2 },
validText: { fontSize: 12, fontFamily: "Inter_400Regular" },
errorBox: { borderRadius: 10, borderWidth: 1, padding: 12, gap: 6 },
errorHeader: { flexDirection: "row", alignItems: "center", gap: 6 },
errorTitle: { fontSize: 12, fontFamily: "Inter_600SemiBold" },
errorScroll: { maxHeight: 80 },
errorDetail: { fontSize: 11, fontFamily: "Inter_400Regular", lineHeight: 16 },
validateBtn: { validateBtn: {
flexDirection: "row", flexDirection: "row", alignItems: "center", justifyContent: "center",
alignItems: "center", gap: 8, paddingVertical: 11, borderRadius: 10, borderWidth: 1,
justifyContent: "center",
gap: 8,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
},
validateText: {
fontSize: 14,
fontFamily: "Inter_600SemiBold",
}, },
validateText: { fontSize: 14, fontFamily: "Inter_600SemiBold" },
formBtnsRow: { flexDirection: "row", gap: 10 },
cancelBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, borderWidth: 1, alignItems: "center" },
cancelText: { fontSize: 14, fontFamily: "Inter_500Medium" },
saveBtn: { saveBtn: {
flexDirection: "row", flex: 2, flexDirection: "row", alignItems: "center", justifyContent: "center",
alignItems: "center", gap: 8, paddingVertical: 12, borderRadius: 10,
justifyContent: "center",
gap: 8,
paddingVertical: 14,
borderRadius: 14,
},
saveText: {
fontSize: 15,
fontFamily: "Inter_600SemiBold",
},
clearBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
},
clearText: {
fontSize: 14,
fontFamily: "Inter_500Medium",
},
footer: {
marginTop: 8,
},
footerText: {
fontSize: 12,
fontFamily: "Inter_400Regular",
textAlign: "center",
lineHeight: 18,
}, },
saveText: { fontSize: 14, fontFamily: "Inter_600SemiBold" },
footerText: { fontSize: 12, fontFamily: "Inter_400Regular", textAlign: "center", lineHeight: 18, marginTop: 4 },
}); });
Binary file not shown.

Before

Width:  |  Height:  |  Size: 652 KiB

After

Width:  |  Height:  |  Size: 49 KiB

@@ -0,0 +1,272 @@
import { Feather } from "@expo/vector-icons";
import { Image } from "expo-image";
import React, { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
FlatList,
Modal,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { PostizWorkspace } from "@/context/PostizContext";
import { useColors } from "@/hooks/useColors";
export interface LibraryMediaItem {
id: string;
path: string;
workspaceId: string;
createdAt?: string;
}
interface RawMediaItem {
id: string;
path: string;
createdAt?: string;
}
interface Props {
visible: boolean;
workspaces: PostizWorkspace[];
defaultWorkspaceId?: string;
maxSelect: number;
onClose: () => void;
onSelect: (items: LibraryMediaItem[]) => void;
onPickFromDevice?: () => 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, workspaces, defaultWorkspaceId, maxSelect, onClose, onSelect, onPickFromDevice }: Props) {
const colors = useColors();
const insets = useSafeAreaInsets();
const [activeId, setActiveId] = useState<string>("");
const [items, setItems] = useState<RawMediaItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const activeWorkspace = workspaces.find((w) => w.id === activeId) ?? workspaces[0] ?? null;
useEffect(() => {
if (visible) {
const initial = defaultWorkspaceId ?? workspaces[0]?.id ?? "";
setActiveId(initial);
setSelected(new Set());
}
}, [visible, defaultWorkspaceId, workspaces]);
const load = useCallback(async () => {
if (!activeWorkspace) return;
setLoading(true);
setError(null);
const apiBase = activeWorkspace.baseUrl.replace(/\/public\/v1$/, "");
const url = `${apiBase}/media?page=0&search=`;
try {
// eslint-disable-next-line no-undef
const res = await globalThis.fetch(url, {
headers: { Authorization: activeWorkspace.apiKey },
});
if (!res.ok) {
if (res.status === 401 || res.status === 403) {
throw new Error("SESSION_REQUIRED");
}
if (res.status === 404) {
throw new Error("ENDPOINT_NOT_FOUND");
}
throw new Error(`HTTP ${res.status}${url}`);
}
const data = await res.json();
const list: RawMediaItem[] = 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);
}
}, [activeWorkspace]);
useEffect(() => {
if (visible && activeWorkspace) {
setSelected(new Set());
load();
}
}, [visible, activeWorkspace, 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 = () => {
if (!activeWorkspace) return;
const chosen = items
.filter((i) => selected.has(i.id))
.map((i): LibraryMediaItem => ({ ...i, workspaceId: activeWorkspace.id }));
onSelect(chosen);
};
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
<View style={[styles.root, { backgroundColor: colors.background, paddingTop: insets.top }]}>
{/* Header */}
<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>
{/* Workspace tabs (only shown when >1 workspace) */}
{workspaces.length > 1 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={[styles.tabs, { borderBottomColor: colors.border }]}
contentContainerStyle={styles.tabsContent}
>
{workspaces.map((ws) => {
const active = ws.id === activeId;
return (
<TouchableOpacity
key={ws.id}
onPress={() => setActiveId(ws.id)}
activeOpacity={0.7}
style={[
styles.tab,
active && { borderBottomColor: colors.primary, borderBottomWidth: 2 },
]}
>
<Text style={[styles.tabText, { color: active ? colors.primary : colors.mutedForeground }]}>
{ws.name}
</Text>
</TouchableOpacity>
);
})}
</ScrollView>
)}
{/* Content */}
{loading ? (
<View style={styles.centered}>
<ActivityIndicator color={colors.primary} size="large" />
</View>
) : error === "SESSION_REQUIRED" ? (
<View style={styles.centered}>
<Feather name="lock" size={28} color={colors.mutedForeground} />
<Text style={[styles.errorText, { color: colors.mutedForeground }]}>
{"Media library requires a web session.\nAPI key access is not supported by Postiz."}
</Text>
{onPickFromDevice && (
<TouchableOpacity
onPress={() => { onClose(); onPickFromDevice(); }}
style={[styles.retryBtn, { backgroundColor: colors.primary }]}
activeOpacity={0.8}
>
<Text style={[styles.retryText, { color: colors.primaryForeground }]}>Use device gallery</Text>
</TouchableOpacity>
)}
</View>
) : error === "ENDPOINT_NOT_FOUND" ? (
<View style={styles.centered}>
<Feather name="slash" size={28} color={colors.mutedForeground} />
<Text style={[styles.errorText, { color: colors.mutedForeground }]}>
{"Media library endpoint not found on this server."}
</Text>
</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, activeWorkspace?.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" },
tabs: { borderBottomWidth: StyleSheet.hairlineWidth, flexGrow: 0 },
tabsContent: { paddingHorizontal: 12, gap: 4 },
tab: { paddingHorizontal: 12, paddingVertical: 12 },
tabText: { fontSize: 13, fontFamily: "Inter_500Medium" },
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
@@ -5,14 +5,22 @@ import React, {
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
useRef,
useState, useState,
} from "react"; } from "react";
const API_KEY_STORAGE = "postiz_api_key"; const WORKSPACES_KEY = "postiz_workspaces_v2";
const BASE_URL_STORAGE = "postiz_base_url"; const LEGACY_API_KEY = "postiz_api_key";
const LEGACY_BASE_URL = "postiz_base_url";
export const DEFAULT_BASE_URL = "https://postiz.gyozamancave.fr/api/public/v1"; export const DEFAULT_BASE_URL = "https://postiz.gyozamancave.fr/api/public/v1";
export interface PostizWorkspace {
id: string;
name: string;
apiKey: string;
baseUrl: string;
}
export interface PostizIntegration { export interface PostizIntegration {
id: string; id: string;
name: string; name: string;
@@ -20,6 +28,7 @@ export interface PostizIntegration {
picture?: string; picture?: string;
identifier?: string; identifier?: string;
internalType?: string; internalType?: string;
customer?: { id: string; name: string };
} }
export interface PostizMediaItem { export interface PostizMediaItem {
@@ -45,129 +54,129 @@ export interface PostizUploadResult {
} }
interface PostizContextValue { interface PostizContextValue {
apiKey: string; workspaces: PostizWorkspace[];
baseUrl: string;
isConfigured: boolean; isConfigured: boolean;
isLoading: boolean; isLoading: boolean;
unauthorized: boolean; clients: Record<string, AxiosInstance>;
clearUnauthorized: () => void; addWorkspace: (ws: Omit<PostizWorkspace, "id">) => Promise<void>;
updateWorkspace: (ws: PostizWorkspace) => Promise<void>;
removeWorkspace: (id: string) => Promise<void>;
// backward compat for posts.tsx (first workspace)
client: AxiosInstance | null; client: AxiosInstance | null;
saveSettings: (apiKey: string, baseUrl: string) => Promise<void>; apiKey: string;
clearSettings: () => Promise<void>; baseUrl: string;
} }
const PostizContext = createContext<PostizContextValue>({ const PostizContext = createContext<PostizContextValue>({
apiKey: "", workspaces: [],
baseUrl: DEFAULT_BASE_URL,
isConfigured: false, isConfigured: false,
isLoading: true, isLoading: true,
unauthorized: false, clients: {},
clearUnauthorized: () => {}, addWorkspace: async () => {},
updateWorkspace: async () => {},
removeWorkspace: async () => {},
client: null, client: null,
saveSettings: async () => {}, apiKey: "",
clearSettings: async () => {}, baseUrl: DEFAULT_BASE_URL,
}); });
function createClient( function makeClient(ws: PostizWorkspace): AxiosInstance {
apiKey: string, const baseURL = ws.baseUrl.endsWith("/") ? ws.baseUrl : ws.baseUrl + "/";
baseUrl: string,
onUnauthorized?: () => void
): AxiosInstance {
const normalizedUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
const instance = axios.create({ const instance = axios.create({
baseURL: normalizedUrl, baseURL,
headers: { headers: { Authorization: ws.apiKey, "Content-Type": "application/json" },
Authorization: apiKey,
"Content-Type": "application/json",
},
timeout: 15000, timeout: 15000,
}); });
instance.interceptors.request.use((config) => { instance.interceptors.request.use((config) => {
console.log(">>> REQUEST:", config.method?.toUpperCase(), (config.baseURL || "") + (config.url || "")); console.log(">>> REQUEST:", config.method?.toUpperCase(), (config.baseURL ?? "") + (config.url ?? ""));
return config; return config;
}); });
instance.interceptors.response.use(
(res) => res,
(err) => {
if (axios.isAxiosError(err) && err.response?.status === 401) {
onUnauthorized?.();
}
return Promise.reject(err);
}
);
return instance; return instance;
} }
function buildClients(list: PostizWorkspace[]): Record<string, AxiosInstance> {
return Object.fromEntries(list.map((ws) => [ws.id, makeClient(ws)]));
}
export function PostizProvider({ children }: { children: React.ReactNode }) { export function PostizProvider({ children }: { children: React.ReactNode }) {
const [apiKey, setApiKey] = useState(""); const [workspaces, setWorkspaces] = useState<PostizWorkspace[]>([]);
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL); const [clients, setClients] = useState<Record<string, AxiosInstance>>({});
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [client, setClient] = useState<AxiosInstance | null>(null);
const [unauthorized, setUnauthorized] = useState(false);
const unauthorizedFiredRef = useRef(false);
const handleUnauthorized = useCallback(() => {
if (unauthorizedFiredRef.current) return;
unauthorizedFiredRef.current = true;
setUnauthorized(true);
}, []);
const clearUnauthorized = useCallback(() => {
unauthorizedFiredRef.current = false;
setUnauthorized(false);
}, []);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
const storedKey = await SecureStore.getItemAsync(API_KEY_STORAGE); const stored = await SecureStore.getItemAsync(WORKSPACES_KEY);
const storedUrl = await SecureStore.getItemAsync(BASE_URL_STORAGE); if (stored) {
if (storedKey) { const list: PostizWorkspace[] = JSON.parse(stored);
const url = (storedUrl || DEFAULT_BASE_URL).replace(/\/$/, ""); setWorkspaces(list);
setApiKey(storedKey); setClients(buildClients(list));
setBaseUrl(url); } else {
setClient(() => createClient(storedKey, url, handleUnauthorized)); // Migrate legacy single-workspace config
const legacyKey = await SecureStore.getItemAsync(LEGACY_API_KEY);
const legacyUrl = await SecureStore.getItemAsync(LEGACY_BASE_URL);
if (legacyKey) {
const migrated: PostizWorkspace = {
id: Date.now().toString(),
name: "Default",
apiKey: legacyKey,
baseUrl: (legacyUrl || DEFAULT_BASE_URL).replace(/\/$/, ""),
};
const list = [migrated];
await SecureStore.setItemAsync(WORKSPACES_KEY, JSON.stringify(list));
setWorkspaces(list);
setClients(buildClients(list));
}
} }
} catch { } catch {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
})(); })();
}, [handleUnauthorized]); }, []);
const saveSettings = useCallback( const persist = useCallback(async (list: PostizWorkspace[]) => {
async (newApiKey: string, newBaseUrl: string) => { await SecureStore.setItemAsync(WORKSPACES_KEY, JSON.stringify(list));
await SecureStore.setItemAsync(API_KEY_STORAGE, newApiKey); setWorkspaces(list);
await SecureStore.setItemAsync(BASE_URL_STORAGE, newBaseUrl); setClients(buildClients(list));
setApiKey(newApiKey); }, []);
setBaseUrl(newBaseUrl);
clearUnauthorized(); const addWorkspace = useCallback(
setClient(() => createClient(newApiKey, newBaseUrl, handleUnauthorized)); async (ws: Omit<PostizWorkspace, "id">) => {
await persist([...workspaces, { ...ws, id: Date.now().toString() }]);
}, },
[handleUnauthorized, clearUnauthorized] [workspaces, persist]
); );
const clearSettings = useCallback(async () => { const updateWorkspace = useCallback(
await SecureStore.deleteItemAsync(API_KEY_STORAGE); async (ws: PostizWorkspace) => {
await SecureStore.deleteItemAsync(BASE_URL_STORAGE); await persist(workspaces.map((w) => (w.id === ws.id ? ws : w)));
setApiKey(""); },
setBaseUrl(DEFAULT_BASE_URL); [workspaces, persist]
setClient(null); );
clearUnauthorized();
}, [clearUnauthorized]); const removeWorkspace = useCallback(
async (id: string) => {
await persist(workspaces.filter((w) => w.id !== id));
},
[workspaces, persist]
);
const primaryWorkspace = workspaces[0] ?? null;
return ( return (
<PostizContext.Provider <PostizContext.Provider
value={{ value={{
apiKey, workspaces,
baseUrl, isConfigured: workspaces.length > 0,
isConfigured: !!apiKey,
isLoading, isLoading,
unauthorized, clients,
clearUnauthorized, addWorkspace,
client, updateWorkspace,
saveSettings, removeWorkspace,
clearSettings, client: primaryWorkspace ? (clients[primaryWorkspace.id] ?? null) : null,
apiKey: primaryWorkspace?.apiKey ?? "",
baseUrl: primaryWorkspace?.baseUrl ?? DEFAULT_BASE_URL,
}} }}
> >
{children} {children}
@@ -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,
+20
View File
@@ -0,0 +1,20 @@
export function stripHtml(html: string | null | undefined): string {
if (!html) return "";
// 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",