2 Commits

Author SHA1 Message Date
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
2 changed files with 81 additions and 78 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
+37 -56
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,7 +140,7 @@ export default function ComposeScreen() {
staleTime: 60000, staleTime: 60000,
}); });
// Group: workspace → network label → integrations // Group: workspace → flat list of integrations
const grouped = useMemo(() => { const grouped = useMemo(() => {
if (!allIntegrations) return []; if (!allIntegrations) return [];
const byWorkspace = new Map<string, IntegrationWithWorkspace[]>(); const byWorkspace = new Map<string, IntegrationWithWorkspace[]>();
@@ -166,21 +150,20 @@ export default function ComposeScreen() {
} }
return workspaces return workspaces
.filter((ws) => byWorkspace.has(ws.id)) .filter((ws) => byWorkspace.has(ws.id))
.map((ws) => { .map((ws) => ({ workspace: ws, channels: byWorkspace.get(ws.id)! }));
const intgs = byWorkspace.get(ws.id)!;
const byNetwork = new Map<string, IntegrationWithWorkspace[]>();
for (const intg of intgs) {
const key = networkLabel(intg);
if (!byNetwork.has(key)) byNetwork.set(key, []);
byNetwork.get(key)!.push(intg);
}
return {
workspace: ws,
networks: Array.from(byNetwork.entries()).map(([label, channels]) => ({ label, channels })),
};
});
}, [allIntegrations, workspaces]); }, [allIntegrations, workspaces]);
const toggleWorkspace = (wsId: string) => {
const wsChannels = grouped.find((g) => g.workspace.id === wsId)?.channels ?? [];
const allIds = wsChannels.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 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,35 +535,38 @@ export default function ComposeScreen() {
</Text> </Text>
) : ( ) : (
<View style={styles.channelGroups}> <View style={styles.channelGroups}>
{grouped.map(({ workspace, networks }, wsIdx) => ( {grouped.map(({ workspace, channels }, wsIdx) => {
const allIds = channels.map((c) => c.id);
const selectedCount = allIds.filter((id) => selectedChannels.includes(id)).length;
const allSelected = selectedCount === allIds.length;
const someSelected = selectedCount > 0 && !allSelected;
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 }]}> <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={allSelected ? "check-square" : someSelected ? "minus-square" : "square"}
size={14}
color={allSelected || someSelected ? colors.primary : colors.mutedForeground}
/>
</TouchableOpacity>
{/* Network groups */} {/* Flat channel chips */}
<View style={styles.networkGroups}>
{networks.map(({ label, channels }) => (
<View key={label} style={styles.networkGroup}>
{networks.length > 1 && (
<Text style={[styles.networkLabel, { color: colors.mutedForeground }]}>
{label}
</Text>
)}
<View style={styles.chipRow}> <View style={styles.chipRow}>
{channels.map((intg) => ( {channels.map((intg) => (
<ChannelChip <ChannelChip
@@ -592,10 +578,8 @@ export default function ComposeScreen() {
))} ))}
</View> </View>
</View> </View>
))} );
</View> })}
</View>
))}
</View> </View>
)} )}
@@ -777,10 +761,7 @@ 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 }, chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 6, padding: 10 },
networkGroup: { gap: 4 },
networkLabel: { fontSize: 10, fontFamily: "Inter_500Medium", letterSpacing: 0.4, marginLeft: 2 },
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
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,