3 Commits

Author SHA1 Message Date
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
3 changed files with 146 additions and 74 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 -72
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;
@@ -156,8 +140,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 +163,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,50 +565,83 @@ 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) => {
<View const wsAllIds = allChannels.map((c) => c.id);
key={workspace.id} const wsSelectedCount = wsAllIds.filter((id) => selectedChannels.includes(id)).length;
style={[ const wsAllSelected = wsSelectedCount === wsAllIds.length;
styles.workspaceSection, const wsSomeSelected = wsSelectedCount > 0 && !wsAllSelected;
{ return (
backgroundColor: colors.card, <View
borderColor: colors.border, key={workspace.id}
}, style={[
wsIdx > 0 && { marginTop: 8 }, styles.workspaceSection,
]} { backgroundColor: colors.card, borderColor: colors.border },
> wsIdx > 0 && { marginTop: 8 },
{/* Workspace header */} ]}
<View style={[styles.workspaceHeader, { borderBottomColor: colors.border }]}> >
<Feather name="briefcase" size={12} color={colors.primary} /> {/* Workspace header — tap to select/deselect all */}
<Text style={[styles.workspaceName, { color: colors.primary }]}> {workspaces.length > 1 && (
{workspace.name} <TouchableOpacity
</Text> activeOpacity={0.7}
</View> 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>
)}
{/* Network groups */} {/* Customer sub-sections */}
<View style={styles.networkGroups}> {customers.map((cust, cIdx) => {
{networks.map(({ label, channels }) => ( const custIds = cust.channels.map((c) => c.id);
<View key={label} style={styles.networkGroup}> const custSelectedCount = custIds.filter((id) => selectedChannels.includes(id)).length;
{networks.length > 1 && ( const custAllSelected = custSelectedCount === custIds.length;
<Text style={[styles.networkLabel, { color: colors.mutedForeground }]}> const custSomeSelected = custSelectedCount > 0 && !custAllSelected;
{label} return (
</Text> <View
)} key={cust.customerId}
<View style={styles.chipRow}> style={cIdx > 0 ? [styles.customerSection, { borderTopColor: colors.border }] : undefined}
{channels.map((intg) => ( >
<ChannelChip {/* Customer header — tap to select/deselect all channels for this customer */}
key={intg.id} <TouchableOpacity
integration={intg} activeOpacity={0.7}
selected={selectedChannels.includes(intg.id)} onPress={() => toggleCustomer(custIds)}
onToggle={() => toggleChannel(intg.id)} 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> );
))} })}
</View> </View>
</View> );
))} })}
</View> </View>
)} )}
@@ -777,10 +823,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,
@@ -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 {