2 Commits

Author SHA1 Message Date
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
5 changed files with 144 additions and 55 deletions
+123 -45
View File
@@ -68,12 +68,11 @@ export default function ComposeScreen() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { workspaces, clients, isConfigured } = 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("");
@@ -95,12 +94,23 @@ export default function ComposeScreen() {
if (prefillIntegrationIds) { if (prefillIntegrationIds) {
setSelectedChannels(String(prefillIntegrationIds).split(",").filter(Boolean)); setSelectedChannels(String(prefillIntegrationIds).split(",").filter(Boolean));
} }
if (prefillImagePath && prefillImageId) { if (prefillImages && workspaces.length > 0) {
// Prefilled image has unknown workspace; associate with first workspace try {
const wsId = workspaces[0]?.id ?? ""; const images: Array<{ id: string; path: string }> = JSON.parse(String(prefillImages));
setMediaItems([{ type: "uploaded", id: String(prefillImageId), path: String(prefillImagePath), workspaceId: wsId }]); 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, workspaces]); }, [prefillContent, prefillIntegrationIds, prefillImages, workspaces]);
useEffect(() => { useEffect(() => {
if (prefillContent) return; if (prefillContent) return;
@@ -140,8 +150,20 @@ export default function ComposeScreen() {
staleTime: 60000, staleTime: 60000,
}); });
// Group: workspace → flat list of integrations type CustomerGroup = {
const grouped = useMemo(() => { customerId: string;
customerName: string;
channels: IntegrationWithWorkspace[];
};
type WorkspaceGroup = {
workspace: PostizWorkspace;
customers: CustomerGroup[];
allChannels: IntegrationWithWorkspace[];
};
// Group: workspace → customers → channels
const grouped = useMemo((): WorkspaceGroup[] => {
if (!allIntegrations) return []; if (!allIntegrations) return [];
const byWorkspace = new Map<string, IntegrationWithWorkspace[]>(); const byWorkspace = new Map<string, IntegrationWithWorkspace[]>();
for (const intg of allIntegrations) { for (const intg of allIntegrations) {
@@ -150,12 +172,21 @@ export default function ComposeScreen() {
} }
return workspaces return workspaces
.filter((ws) => byWorkspace.has(ws.id)) .filter((ws) => byWorkspace.has(ws.id))
.map((ws) => ({ workspace: ws, channels: byWorkspace.get(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]); }, [allIntegrations, workspaces]);
const toggleWorkspace = (wsId: string) => { const toggleWorkspace = (wsId: string) => {
const wsChannels = grouped.find((g) => g.workspace.id === wsId)?.channels ?? []; const allIds = (grouped.find((g) => g.workspace.id === wsId)?.allChannels ?? []).map((c) => c.id);
const allIds = wsChannels.map((c) => c.id);
const allSelected = allIds.every((id) => selectedChannels.includes(id)); const allSelected = allIds.every((id) => selectedChannels.includes(id));
if (allSelected) { if (allSelected) {
setSelectedChannels((prev) => prev.filter((id) => !allIds.includes(id))); setSelectedChannels((prev) => prev.filter((id) => !allIds.includes(id)));
@@ -164,6 +195,15 @@ export default function ComposeScreen() {
} }
}; };
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(() => { const effectiveCharLimit = useMemo(() => {
if (selectedChannels.length === 0 || !allIntegrations) return 3000; if (selectedChannels.length === 0 || !allIntegrations) return 3000;
const selected = allIntegrations.filter((i) => selectedChannels.includes(i.id)); const selected = allIntegrations.filter((i) => selectedChannels.includes(i.id));
@@ -535,11 +575,11 @@ export default function ComposeScreen() {
</Text> </Text>
) : ( ) : (
<View style={styles.channelGroups}> <View style={styles.channelGroups}>
{grouped.map(({ workspace, channels }, wsIdx) => { {grouped.map(({ workspace, customers, allChannels }, wsIdx) => {
const allIds = channels.map((c) => c.id); const wsAllIds = allChannels.map((c) => c.id);
const selectedCount = allIds.filter((id) => selectedChannels.includes(id)).length; const wsSelectedCount = wsAllIds.filter((id) => selectedChannels.includes(id)).length;
const allSelected = selectedCount === allIds.length; const wsAllSelected = wsSelectedCount === wsAllIds.length;
const someSelected = selectedCount > 0 && !allSelected; const wsSomeSelected = wsSelectedCount > 0 && !wsAllSelected;
return ( return (
<View <View
key={workspace.id} key={workspace.id}
@@ -550,33 +590,65 @@ export default function ComposeScreen() {
]} ]}
> >
{/* Workspace header — tap to select/deselect all */} {/* Workspace header — tap to select/deselect all */}
<TouchableOpacity {workspaces.length > 1 && (
activeOpacity={0.7} <TouchableOpacity
onPress={() => toggleWorkspace(workspace.id)} activeOpacity={0.7}
style={[styles.workspaceHeader, { borderBottomColor: colors.border }]} 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 }]}> <Feather name="briefcase" size={12} color={colors.primary} />
{workspace.name} <Text style={[styles.workspaceName, { color: colors.primary, flex: 1 }]}>
</Text> {workspace.name}
<Feather </Text>
name={allSelected ? "check-square" : someSelected ? "minus-square" : "square"} <Feather
size={14} name={wsAllSelected ? "check-square" : wsSomeSelected ? "minus-square" : "square"}
color={allSelected || someSelected ? colors.primary : colors.mutedForeground} size={14}
/> color={wsAllSelected || wsSomeSelected ? colors.primary : colors.mutedForeground}
</TouchableOpacity>
{/* Flat channel chips */}
<View style={styles.chipRow}>
{channels.map((intg) => (
<ChannelChip
key={intg.id}
integration={intg}
selected={selectedChannels.includes(intg.id)}
onToggle={() => toggleChannel(intg.id)}
/> />
))} </TouchableOpacity>
</View> )}
{/* 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
key={intg.id}
integration={intg}
selected={selectedChannels.includes(intg.id)}
onToggle={() => toggleChannel(intg.id)}
/>
))}
</View>
</View>
);
})}
</View> </View>
); );
})} })}
@@ -761,7 +833,13 @@ const styles = StyleSheet.create({
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
}, },
workspaceName: { fontSize: 11, fontFamily: "Inter_600SemiBold", letterSpacing: 0.5 }, workspaceName: { fontSize: 11, fontFamily: "Inter_600SemiBold", letterSpacing: 0.5 },
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 6, padding: 10 }, customerSection: { borderTopWidth: StyleSheet.hairlineWidth },
customerHeader: {
flexDirection: "row", alignItems: "center", justifyContent: "space-between",
paddingHorizontal: 12, paddingVertical: 8,
},
customerName: { fontSize: 13, fontFamily: "Inter_600SemiBold" },
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 6, paddingHorizontal: 10, paddingBottom: 10 },
scheduleRow: { scheduleRow: {
flexDirection: "row", alignItems: "center", justifyContent: "space-between", flexDirection: "row", alignItems: "center", justifyContent: "space-between",
paddingHorizontal: 16, paddingVertical: 12, borderRadius: 12, borderWidth: 1, paddingHorizontal: 16, paddingVertical: 12, borderRadius: 12, borderWidth: 1,
+8 -7
View File
@@ -152,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: { prefillIntegrationIds: integrations.map((i) => i.id).join(","),
prefillContent: stripHtml(post.content), };
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) => {
@@ -66,12 +66,20 @@ export function MediaLibraryModal({ visible, workspaces, defaultWorkspaceId, max
if (!activeWorkspace) return; if (!activeWorkspace) return;
setLoading(true); setLoading(true);
setError(null); setError(null);
const url = `${activeWorkspace.baseUrl}/media`;
try { try {
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
const res = await globalThis.fetch(`${activeWorkspace.baseUrl}/media`, { const res = await globalThis.fetch(url, {
headers: { Authorization: activeWorkspace.apiKey }, headers: { Authorization: activeWorkspace.apiKey },
}); });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) {
if (res.status === 404) {
throw new Error(
`Media listing endpoint not found (404).\nURL tried: ${url}\n\nThis feature requires Postiz to expose GET /media in its public API. Your version may not support it yet.`
);
}
throw new Error(`HTTP ${res.status}${url}`);
}
const data = await res.json(); const data = await res.json();
const list: RawMediaItem[] = Array.isArray(data) const list: RawMediaItem[] = Array.isArray(data)
? data ? data
@@ -28,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 {
+2 -1
View File
@@ -1,4 +1,5 @@
export function stripHtml(html: string): string { export function stripHtml(html: string | null | undefined): string {
if (!html) return "";
// Decode entities first so encoded tags like &lt;p&gt; are also stripped // Decode entities first so encoded tags like &lt;p&gt; are also stripped
let s = html let s = html
.replace(/&amp;/g, "&") .replace(/&amp;/g, "&")