Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cf5800463 | |||
| 20ca6e0334 | |||
| 9abd05d05a | |||
| 59b688dafb |
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -777,10 +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 },
|
||||||
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,
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 <p> are also stripped
|
// Decode entities first so encoded tags like <p> are also stripped
|
||||||
let s = html
|
let s = html
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
|
|||||||
Reference in New Issue
Block a user