5 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
6 changed files with 212 additions and 93 deletions
+24 -2
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,8 +129,7 @@ jobs:
body: | body: |
## Postiz Mobile ${{ github.ref_name }} ## Postiz Mobile ${{ github.ref_name }}
Signed APK for Android — direct install (sideload). ${{ steps.changelog.outputs.changelog }}
### Installation ### Installation
1. Enable "Unknown sources" on the device 1. Enable "Unknown sources" on the device
2. Transfer the APK to the device and open it to install 2. Transfer the APK to the device and open it to install
+121 -61
View File
@@ -56,22 +56,6 @@ type MediaItem =
| { type: "local"; uri: string } | { type: "local"; uri: string }
| { type: "uploaded"; id: string; path: string; workspaceId: string }; | { type: "uploaded"; id: string; path: string; workspaceId: string };
// Maps a type string to a display label, used for grouping within a workspace
function networkLabel(intg: PostizIntegration): string {
const t = (intg.type ?? intg.internalType ?? "").toLowerCase();
if (t.includes("twitter") || t.includes("x-") || t === "x") return "X / Twitter";
if (t.includes("instagram")) return "Instagram";
if (t.includes("linkedin")) return "LinkedIn";
if (t.includes("facebook")) return "Facebook";
if (t.includes("tiktok")) return "TikTok";
if (t.includes("youtube")) return "YouTube";
if (t.includes("pinterest")) return "Pinterest";
if (t.includes("mastodon")) return "Mastodon";
if (t.includes("bluesky") || t.includes("bsky")) return "Bluesky";
if (t.includes("threads")) return "Threads";
if (t.includes("reddit")) return "Reddit";
return "Other";
}
function resolveMediaUrl(path: string, baseUrl: string): string { function resolveMediaUrl(path: string, baseUrl: string): string {
if (path.startsWith("http://") || path.startsWith("https://")) return path; if (path.startsWith("http://") || path.startsWith("https://")) return path;
@@ -84,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("");
@@ -111,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 images: Array<{ id: string; path: string }> = JSON.parse(String(prefillImages));
const wsId = workspaces[0]?.id ?? ""; const wsId = workspaces[0]?.id ?? "";
setMediaItems([{ type: "uploaded", id: String(prefillImageId), path: String(prefillImagePath), workspaceId: wsId }]); 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;
@@ -156,8 +150,20 @@ export default function ComposeScreen() {
staleTime: 60000, staleTime: 60000,
}); });
// Group: workspace → network label → 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) {
@@ -167,20 +173,37 @@ export default function ComposeScreen() {
return workspaces return workspaces
.filter((ws) => byWorkspace.has(ws.id)) .filter((ws) => byWorkspace.has(ws.id))
.map((ws) => { .map((ws) => {
const intgs = byWorkspace.get(ws.id)!; const allChannels = byWorkspace.get(ws.id)!;
const byNetwork = new Map<string, IntegrationWithWorkspace[]>(); const byCustomer = new Map<string, CustomerGroup>();
for (const intg of intgs) { for (const ch of allChannels) {
const key = networkLabel(intg); const cId = ch.customer?.id ?? "__default__";
if (!byNetwork.has(key)) byNetwork.set(key, []); const cName = ch.customer?.name ?? ws.name;
byNetwork.get(key)!.push(intg); if (!byCustomer.has(cId)) byCustomer.set(cId, { customerId: cId, customerName: cName, channels: [] });
byCustomer.get(cId)!.channels.push(ch);
} }
return { return { workspace: ws, customers: Array.from(byCustomer.values()), allChannels };
workspace: ws,
networks: Array.from(byNetwork.entries()).map(([label, channels]) => ({ label, channels })),
};
}); });
}, [allIntegrations, workspaces]); }, [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(() => { 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));
@@ -552,37 +575,69 @@ export default function ComposeScreen() {
</Text> </Text>
) : ( ) : (
<View style={styles.channelGroups}> <View style={styles.channelGroups}>
{grouped.map(({ workspace, networks }, wsIdx) => ( {grouped.map(({ workspace, customers, allChannels }, wsIdx) => {
const wsAllIds = allChannels.map((c) => c.id);
const wsSelectedCount = wsAllIds.filter((id) => selectedChannels.includes(id)).length;
const wsAllSelected = wsSelectedCount === wsAllIds.length;
const wsSomeSelected = wsSelectedCount > 0 && !wsAllSelected;
return (
<View <View
key={workspace.id} key={workspace.id}
style={[ style={[
styles.workspaceSection, styles.workspaceSection,
{ { backgroundColor: colors.card, borderColor: colors.border },
backgroundColor: colors.card,
borderColor: colors.border,
},
wsIdx > 0 && { marginTop: 8 }, wsIdx > 0 && { marginTop: 8 },
]} ]}
> >
{/* Workspace header */} {/* Workspace header — tap to select/deselect all */}
<View style={[styles.workspaceHeader, { borderBottomColor: colors.border }]}> {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} /> <Feather name="briefcase" size={12} color={colors.primary} />
<Text style={[styles.workspaceName, { color: colors.primary }]}> <Text style={[styles.workspaceName, { color: colors.primary, flex: 1 }]}>
{workspace.name} {workspace.name}
</Text> </Text>
</View> <Feather
name={wsAllSelected ? "check-square" : wsSomeSelected ? "minus-square" : "square"}
{/* Network groups */} size={14}
<View style={styles.networkGroups}> color={wsAllSelected || wsSomeSelected ? colors.primary : colors.mutedForeground}
{networks.map(({ label, channels }) => ( />
<View key={label} style={styles.networkGroup}> </TouchableOpacity>
{networks.length > 1 && (
<Text style={[styles.networkLabel, { color: colors.mutedForeground }]}>
{label}
</Text>
)} )}
{/* 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}> <View style={styles.chipRow}>
{channels.map((intg) => ( {cust.channels.map((intg) => (
<ChannelChip <ChannelChip
key={intg.id} key={intg.id}
integration={intg} integration={intg}
@@ -592,10 +647,11 @@ export default function ComposeScreen() {
))} ))}
</View> </View>
</View> </View>
))} );
})}
</View> </View>
</View> );
))} })}
</View> </View>
)} )}
@@ -711,6 +767,7 @@ export default function ComposeScreen() {
workspaces={workspaces} workspaces={workspaces}
maxSelect={MAX_IMAGES - mediaItems.length} maxSelect={MAX_IMAGES - mediaItems.length}
onClose={() => setShowMediaLibrary(false)} onClose={() => setShowMediaLibrary(false)}
onPickFromDevice={() => { setShowMediaLibrary(false); pickImage(); }}
onSelect={(items: LibraryMediaItem[]) => { onSelect={(items: LibraryMediaItem[]) => {
setMediaItems((prev) => setMediaItems((prev) =>
[ [
@@ -777,10 +834,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 },
networkGroups: { padding: 10, gap: 10 }, customerSection: { borderTopWidth: StyleSheet.hairlineWidth },
networkGroup: { gap: 4 }, customerHeader: {
networkLabel: { fontSize: 10, fontFamily: "Inter_500Medium", letterSpacing: 0.4, marginLeft: 2 }, flexDirection: "row", alignItems: "center", justifyContent: "space-between",
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 6 }, 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,
+6 -5
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",
params: {
prefillContent: stripHtml(post.content), prefillContent: stripHtml(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) => {
@@ -35,6 +35,7 @@ interface Props {
maxSelect: number; maxSelect: number;
onClose: () => void; onClose: () => void;
onSelect: (items: LibraryMediaItem[]) => void; onSelect: (items: LibraryMediaItem[]) => void;
onPickFromDevice?: () => void;
} }
function resolveUrl(path: string, baseUrl: string): string { function resolveUrl(path: string, baseUrl: string): string {
@@ -43,7 +44,7 @@ function resolveUrl(path: string, baseUrl: string): string {
return `${origin}/${path.replace(/^\//, "")}`; return `${origin}/${path.replace(/^\//, "")}`;
} }
export function MediaLibraryModal({ visible, workspaces, defaultWorkspaceId, maxSelect, onClose, onSelect }: Props) { export function MediaLibraryModal({ visible, workspaces, defaultWorkspaceId, maxSelect, onClose, onSelect, onPickFromDevice }: Props) {
const colors = useColors(); const colors = useColors();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [activeId, setActiveId] = useState<string>(""); const [activeId, setActiveId] = useState<string>("");
@@ -66,12 +67,22 @@ export function MediaLibraryModal({ visible, workspaces, defaultWorkspaceId, max
if (!activeWorkspace) return; if (!activeWorkspace) return;
setLoading(true); setLoading(true);
setError(null); setError(null);
const apiBase = activeWorkspace.baseUrl.replace(/\/public\/v1$/, "");
const url = `${apiBase}/media?page=0&search=`;
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 === 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 data = await res.json();
const list: RawMediaItem[] = Array.isArray(data) const list: RawMediaItem[] = Array.isArray(data)
? data ? data
@@ -163,6 +174,29 @@ export function MediaLibraryModal({ visible, workspaces, defaultWorkspaceId, max
<View style={styles.centered}> <View style={styles.centered}>
<ActivityIndicator color={colors.primary} size="large" /> <ActivityIndicator color={colors.primary} size="large" />
</View> </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 ? ( ) : error ? (
<View style={styles.centered}> <View style={styles.centered}>
<Feather name="alert-circle" size={28} color={colors.error} /> <Feather name="alert-circle" size={28} color={colors.error} />
@@ -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, "&")