19 Commits

Author SHA1 Message Date
billisdead 40c2ce20f3 feat: resize images to max 1920px before upload
Release APK / build (push) Has been cancelled
Add expo-image-manipulator. In pickImage(), detect if image dimensions
exceed 1920px and resize (keeping aspect ratio) + compress to JPEG 0.85.
Previously only JPEG quality was set but dimensions were untouched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 15:58:12 +02:00
billisdead aa516667cd fix(ci): skip on Gitea + add contents:write for release creation
Release APK / build (push) Has been cancelled
- Add job condition `github.server_url == 'https://github.com'` so Gitea
  (no runner) ignores the workflow entirely
- Add `permissions: contents: write` required by softprops/action-gh-release

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 13:36:13 +02:00
billisdead f6fcf35cf8 fix(ci): add caching + remove unnecessary expo-cli install to fix 30m timeout
Release APK / build (push) Has been cancelled
- Cache pnpm store, Android NDK (28.2.13676358, ~1.5 GB), and Gradle
- Skip sdkmanager NDK install on cache hit
- Remove global expo-cli install (already in devDeps as @expo/cli)
- Increase timeout 30m → 60m for first cold build

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 10:29:06 +02:00
billisdead 2869f4ee5f fix(ci): use --no-frozen-lockfile to handle pnpm overrides platform mismatch
Release APK / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 09:09:12 +02:00
billisdead 614a353b3c chore: remove Replit/homegit references for public GitHub repo
- Simplify dev script (drop Replit-specific env vars)
- Remove REPLIT_* fallbacks from scripts/build.js
- Rewrite root README for GitHub (local build, no EAS, no homegit URLs)
- Update clone URL in artifacts README → GitHub
- Remove replit.md (Replit workspace descriptor, no longer needed)
- Untrack scripts/push-to-gitea.sh (internal-only, added to .gitignore)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 09:05:46 +02:00
billisdead 7111162f14 fix: resolve pnpm monorepo Metro bundling + add GitHub release workflow
- Add babel-preset-expo as explicit devDep (fixes "Cannot find module" crash)
- Configure metro.config.js with watchFolders for pnpm workspace root
- Bump expo devDep to ~54.0.34, remove duplicate deps/devDeps
- Add .github/workflows/release.yml: signed APK on git tag vX.Y.Z

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 08:57:35 +02:00
billisdead aaf6b2aa07 fix: force JAVA_HOME to ~/jdk21 when system Java ≥ 25
Gradle 8.x supports up to Java 24. Fedora 44 ships Java 25 by default,
causing "Unsupported class file major version 69" at build time.

build-apk.sh now auto-detects system Java version and falls back to
~/jdk21 (Temurin 21 LTS) when Java ≥ 25 is detected.
README documents the one-time JDK 21 install step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 20:52:54 +02:00
billisdead e051ce8e7f docs: rewrite README for local build workflow, drop EAS references
- Remove EAS/expo.dev from prerequisites and build sections
- Document build-apk.sh workflow step by step
- Document first-time setup (Android SDK + keystore export + signing.env)
- Update architecture tree (add lib/extractError.ts, remove expo-task-manager)
- Remove Replit-specific sections (dev server, push via bundle, env vars)
- Keep minimal eas.json (needed only for one-time keystore export via eas credentials)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 20:48:44 +02:00
billisdead 7f39d79190 fix: correct NDK package name to ndk;28.2.13676358 (stable) 2026-06-07 20:45:10 +02:00
billisdead 979a5c1dd3 fix: replace broken expo config plugin with post-prebuild Python patch
The expo config plugin approach failed because eas-cli (global install)
loads app.json plugins in its own module resolution context and cannot
find @expo/config-plugins from the project's local node_modules.

Replace with a Python3 inline script in build-apk.sh that patches
android/app/build.gradle after expo prebuild:
- Inserts a release signingConfig block (reads from gradle.properties)
- Switches release buildType from signingConfigs.debug to .release

Removes plugins/withAndroidReleaseSigning.js and its app.json entry.
No npm dependency required — Python3 is available on any Linux host.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 20:36:38 +02:00
billisdead 20226caef4 build: remove expo.dev/EAS dependency, add local Android build pipeline
- Delete eas.json and strip extra.eas.projectId from app.json
- Add plugins/withAndroidReleaseSigning.js — Expo config plugin that injects
  release signingConfig into the generated build.gradle during expo prebuild
- Add build-apk.sh — self-contained build script (expo prebuild + Gradle)
  Reads keystore credentials from ~/.config/postiz-mobile/signing.env
  Outputs APK/AAB to dist/, wipes credentials from gradle.properties after build
- Add install-android-sdk.sh — one-time Android SDK cmdline-tools bootstrap
- Remove unused expo-task-manager dependency
- Update .gitignore: android/, ios/, static-build/ excluded (generated)

Build workflow:
  1. eas credentials --platform android  # export keystore once
  2. ./install-android-sdk.sh            # first time only
  3. ./build-apk.sh                      # → dist/postiz-mobile-YYYYMMDD-HHMM.apk

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 20:32:18 +02:00
billisdead 7aacb9a53e feat: UX improvements, security hardening, and code cleanup
- Extract shared extractError utility (lib/extractError.ts), remove 3 duplicate copies
- Export DEFAULT_BASE_URL from PostizContext, remove duplicate in settings
- Add 401 interceptor in axios client: fires UnauthorizedHandler in _layout → alert + redirect to Settings
- Calendar day items now tappable: tap opens context menu (Copy / Edit / Repost)
- Persist sort order (newest/oldest) across sessions via AsyncStorage
- Filter chips show post count per status (Queue 3, Error 1, etc.)
- Copy text action now shows a brief "Copied" toast + haptic feedback
- PostCard: swipe right → Reschedule action (QUEUE posts only, amber color)
- Compose: per-network char limit (Twitter 280, Instagram 2200…) with color warning at 90%
- Compose: local draft save/restore via AsyncStorage with restore banner on open
- Compose: prefillImagePath/prefillImageId params allow Edit/Repost to carry over existing media

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 22:20:56 +02:00
billisdead bc0973ccaa docs: translate README files from French to English
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 13:17:04 +02:00
billisdead 4dc746514a feat: add sort toggle (newest/oldest) in posts filter bar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 13:40:56 +02:00
billisdead 55d283c264 fix: reschedule via delete+recreate, sort posts chrono, show account name
- Reschedule: Postiz public API v1 has no PUT/PATCH on posts; implement
  as delete + recreate with updated date and same content/integrations
- Posts list: sort ascending by publishDate so nearest post appears first
- PostCard footer: show integration name (or identifier) before the
  timestamp, truncated to 2 accounts with +N overflow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 13:19:41 +02:00
billisdead e1a294fc96 fix: always include image array in post value to satisfy Postiz API validation
Postiz requires posts[].value[].image to always be an array. Omitting it
when no image is selected was causing a 400 Bad Request.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 09:16:07 +02:00
billisdead 554b16d6cb feat: enable internal distribution on preview build profile
Adds distribution: "internal" to the preview EAS build profile so that
completed APK builds generate a QR code and direct download link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 07:49:33 +02:00
billisdead be57d581ac debug: log POST body and full 400 response in compose
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 23:32:36 +02:00
billisdead 3d2ba858bb fix: omit empty image array and bogus id from post value items
Sending image:[] causes a 400 from the Postiz API — only include the
image field when there is actual media to attach. Also remove the id:""
field which was never valid.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 22:51:59 +02:00
22 changed files with 1157 additions and 911 deletions
+116
View File
@@ -0,0 +1,116 @@
name: Release APK
on:
push:
tags:
- 'v*.*.*'
jobs:
build:
if: github.server_url == 'https://github.com'
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Get pnpm store path
id: pnpm-cache
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Install dependencies
working-directory: artifacts/postiz-mobile
run: pnpm install --no-frozen-lockfile
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Accept Android SDK licenses
run: yes | sdkmanager --licenses || true
- name: Cache Android NDK
id: ndk-cache
uses: actions/cache@v4
with:
path: /usr/local/lib/android/sdk/ndk/28.2.13676358
key: ndk-28.2.13676358-v1
- name: Install SDK components
run: |
sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
if [ "${{ steps.ndk-cache.outputs.cache-hit }}" != "true" ]; then
sdkmanager "ndk;28.2.13676358"
fi
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('artifacts/postiz-mobile/package.json') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Decode keystore
run: |
mkdir -p ~/.config/postiz-mobile
echo "${{ secrets.KEYSTORE_B64 }}" | base64 -d > ~/.config/postiz-mobile/postiz-mobile.jks
cat > ~/.config/postiz-mobile/signing.env <<EOF
KEYSTORE_PATH=~/.config/postiz-mobile/postiz-mobile.jks
KEYSTORE_ALIAS=${{ secrets.KEYSTORE_ALIAS }}
KEYSTORE_STORE_PASSWORD=${{ secrets.KEYSTORE_STORE_PASSWORD }}
KEYSTORE_KEY_PASSWORD=${{ secrets.KEYSTORE_KEY_PASSWORD }}
EOF
chmod 600 ~/.config/postiz-mobile/signing.env ~/.config/postiz-mobile/postiz-mobile.jks
- name: Build signed APK
working-directory: artifacts/postiz-mobile
run: ./build-apk.sh
- name: Find built APK
id: apk
run: |
APK=$(ls artifacts/postiz-mobile/dist/*.apk | sort | tail -1)
echo "path=$APK" >> "$GITHUB_OUTPUT"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: "Postiz Mobile ${{ github.ref_name }}"
body: |
## Postiz Mobile ${{ github.ref_name }}
APK signé pour Android — installation directe (sideload).
### Installation
1. Activer "Sources inconnues" sur l'appareil
2. Transférer l'APK et ouvrir pour installer
files: ${{ steps.apk.outputs.path }}
draft: false
prerelease: ${{ contains(github.ref_name, '-') }}
+1
View File
@@ -47,3 +47,4 @@ Thumbs.db
# Replit # Replit
.cache/ .cache/
.local/ .local/
scripts/push-to-gitea.sh
+54 -325
View File
@@ -1,381 +1,110 @@
# PostizMobile # PostizMobile
Application mobile React Native (Expo) pour piloter une instance **Postiz** auto-hébergée depuis votre téléphone Android ou iOS. React Native (Expo) mobile app to control a self-hosted [Postiz](https://postiz.com) instance from Android.
Build is fully local — no expo.dev account or EAS cloud required.
--- ---
## Sommaire ## Features
1. [Fonctionnalités](#fonctionnalités) | Screen | Description |
2. [Prérequis](#prérequis) |--------|-------------|
3. [Installation & développement](#installation--développement) | **Calendar** | Monthly view with color dots per day (indigo = scheduled, green = published, red = error). Tap a day to see its posts. |
4. [Configuration de l'application](#configuration-de-lapplication) | **Posts** | Filtered list (All / Queue / Published / Draft / Error) with sort toggle, pull-to-refresh, swipe left to delete, swipe right to reschedule. |
5. [Architecture du projet](#architecture-du-projet) | **Compose** | Text editor with per-network character limit, channel picker, date/time picker, gallery image pick + upload, publish now or schedule. Local draft save/restore. |
6. [API Postiz utilisée](#api-postiz-utilisée) | **Settings** | API key and base URL, connection test, secure storage. 401 auto-redirect to Settings. |
7. [Build APK Android (EAS)](#build-apk-android-eas) | **Notifications** | Local alerts when a post transitions to PUBLISHED or ERROR (polling every 15 min). |
8. [Build iOS (Expo Launch)](#build-ios-expo-launch)
9. [Pousser les modifications sur Gitea](#pousser-les-modifications-sur-gitea) **Theme**: forced dark. **Auth**: API key in `expo-secure-store`, never hardcoded.
10. [Variables d'environnement & secrets](#variables-denvironnement--secrets)
11. [Dépannage](#dépannage)
--- ---
## Fonctionnalités ## Prerequisites
| Écran | Description | | Tool | Version |
|-------|-------------| |------|---------|
| **Calendrier** | Vue mensuelle avec points de couleur par jour (indigo = planifié, vert = publié, rouge = erreur). Tap sur un jour pour voir les posts. |
| **Posts** | Liste filtrée (Tous / Queue / Publié / Brouillon / Erreur) avec pull-to-refresh et swipe gauche pour supprimer. |
| **Composer** | Éditeur de texte, sélecteur de canaux, date/heure, importation d'image galerie + upload, publier maintenant ou planifier. |
| **Paramètres** | Saisie de la clé API et de l'URL de base, test de connexion, sauvegarde sécurisée (SecureStore). |
| **Notifications** | Alertes locales automatiques quand un post passe à PUBLISHED ou ERROR (polling toutes les 15 minutes). |
**Thème** : dark forcé (`userInterfaceStyle: dark`).
**Authentification** : clé API stockée dans `expo-secure-store`, jamais en dur dans le code.
---
## Prérequis
| Outil | Version minimale |
|-------|-----------------|
| Node.js | 20 LTS | | Node.js | 20 LTS |
| pnpm | 10+ | | pnpm | 10+ |
| Expo Go (téléphone) | SDK 54 compatible | | Java (JDK) | 1724 (Java 25+ not yet supported by Gradle 8) |
| Compte EAS (pour APK) | gratuit sur expo.dev | | Android SDK | see below |
```bash
npm install -g pnpm
npm install -g eas-cli
```
--- ---
## Installation & veloppement ## Installation & Development
### 1. Cloner le dépôt
```bash
git clone ssh://gitea@homegit.gyozamancave.fr:2222/billisdead/Postiz-android.git
cd Postiz-android
```
### 2. Installer les dépendances
```bash ```bash
git clone https://github.com/pirona/postiz-android.git
cd postiz-android
pnpm install pnpm install
``` ```
### 3. Lancer le serveur de développement Start the dev server (requires Expo Go on the device):
```bash ```bash
pnpm --filter @workspace/postiz-mobile run dev pnpm --filter @workspace/postiz-mobile run dev
``` ```
Le terminal affiche un QR code. Scannez-le avec **Expo Go** (Android) ou l'app **Appareil photo** (iOS) pour voir l'app en direct.
### 4. Ouvrir dans le navigateur (web preview)
```
http://localhost:<PORT>
```
Le port est assigné dynamiquement par l'environnement Replit.
--- ---
## Configuration de l'application ## Building an APK (local, no EAS)
Au premier lancement, l'écran **Paramètres** s'affiche car aucune clé n'est configurée. See **[artifacts/postiz-mobile/README.md](artifacts/postiz-mobile/README.md)** for the full build guide.
1. **URL de base** : `https://votre-instance-postiz.fr/public/v1` Quick start:
2. **Clé API** : générée depuis votre instance Postiz → *Settings → API Keys*
3. Appuyez sur **Test Connection** pour valider
4. Appuyez sur **Save Settings**
La clé est chiffrée et stockée localement via `expo-secure-store`. Elle n'est jamais envoyée à un service tiers.
---
## Architecture du projet
```
artifacts/postiz-mobile/
├── app/
│ ├── _layout.tsx # Root layout : providers, fonts, notifications
│ └── (tabs)/
│ ├── _layout.tsx # Tab bar (NativeTabs iOS 26+ / Tabs classique)
│ ├── index.tsx # Écran Calendrier
│ ├── posts.tsx # Écran Liste des posts
│ ├── compose.tsx # Écran Composer
│ └── settings.tsx # Écran Paramètres
├── components/
│ ├── ChannelChip.tsx # Chip de sélection de canal
│ ├── ErrorBoundary.tsx # Gestionnaire d'erreurs global
│ ├── PostCard.tsx # Carte post avec swipe-to-delete
│ └── StatusBadge.tsx # Badge QUEUE / PUBLISHED / ERROR / DRAFT
├── constants/
│ └── colors.ts # Palette dark theme
├── context/
│ └── PostizContext.tsx # Client axios + SecureStore (apiKey, baseUrl)
├── hooks/
│ ├── useColors.ts # Tokens couleur selon le thème
│ └── useNotifications.ts # Permissions + polling + notifications locales
├── assets/
│ └── images/
│ └── icon.png # Icône générée par IA
└── app.json # Config Expo (permissions, plugins, thème)
```
### Dépendances principales
| Package | Usage |
|---------|-------|
| `expo-router` | Navigation file-based |
| `axios` | Client HTTP vers l'API Postiz |
| `expo-secure-store` | Stockage chiffré de la clé API |
| `react-native-calendars` | Vue calendrier mensuelle |
| `@react-native-community/datetimepicker` | Sélecteur date/heure dans Composer |
| `expo-image-picker` | Accès galerie photos |
| `expo-notifications` | Notifications locales de statut |
| `expo-task-manager` | Tâche de fond pour le polling |
| `@tanstack/react-query` | Cache et refetch des données API |
---
## API Postiz utilisée
Base URL configurée par l'utilisateur (ex. `https://postiz.example.com/public/v1`).
| Méthode | Endpoint | Usage |
|---------|----------|-------|
| `GET` | `/integrations` | Lister les canaux (Twitter, LinkedIn, etc.) |
| `GET` | `/posts?startDate=&endDate=` | Posts sur une plage de dates |
| `POST` | `/posts` | Créer / planifier un post |
| `DELETE` | `/posts/:id` | Supprimer un post |
| `POST` | `/upload` | Uploader une image (multipart) |
### Exemple de payload POST /posts
```json
{
"type": "schedule",
"date": "2025-01-15T10:00:00.000Z",
"content": [
{
"content": "Mon super post 🚀",
"image": [{ "id": "upload-id", "path": "/uploads/photo.jpg" }]
}
],
"integrations": ["integration-id-twitter", "integration-id-linkedin"]
}
```
Pour publier immédiatement, utilisez `"type": "now"`.
---
## Build APK Android (EAS)
> **Prérequis** : compte gratuit sur [expo.dev](https://expo.dev) et `eas-cli` installé.
### 1. Se connecter à EAS
```bash
npx eas login
```
### 2. Initialiser EAS dans le projet
```bash ```bash
cd artifacts/postiz-mobile cd artifacts/postiz-mobile
npx eas init ./install-android-sdk.sh # first time only
cp ~/.config/postiz-mobile/signing.env.example ~/.config/postiz-mobile/signing.env
$EDITOR ~/.config/postiz-mobile/signing.env # fill in keystore credentials
./build-apk.sh # → dist/postiz-mobile-YYYYMMDD-HHMM.apk
``` ```
Cela génère un `projectId` dans `app.json`. ### GitHub Actions release
### 3. Fichier de configuration EAS Pushing a tag triggers an automated signed APK release:
> **Déjà inclus dans le dépôt** : `artifacts/postiz-mobile/eas.json` est présent, vous pouvez passer cette étape.
Si vous souhaitez le recréer manuellement :
```json
{
"cli": {
"version": ">= 16.0.0"
},
"build": {
"preview": {
"android": {
"buildType": "apk"
}
},
"production": {
"android": {
"buildType": "app-bundle"
}
}
},
"submit": {
"production": {}
}
}
```
### 4. Lancer le build APK
```bash ```bash
# APK de test (sideload) git tag v1.0.0
npx eas build --platform android --profile preview git push origin --tags
# AAB pour le Play Store
npx eas build --platform android --profile production
``` ```
Le build se fait dans le cloud EAS (~10-15 min). À la fin, EAS affiche un **lien de téléchargement** et un **QR code** pour récupérer le fichier `.apk`. The workflow builds the APK on GitHub's infrastructure and attaches it to a GitHub Release.
Required secrets: `KEYSTORE_B64`, `KEYSTORE_ALIAS`, `KEYSTORE_STORE_PASSWORD`, `KEYSTORE_KEY_PASSWORD`.
### 5. Installer l'APK sur votre téléphone
```bash
# Via adb
adb install postiz-mobile.apk
# Ou scannez le QR code affiché par EAS
```
### 6. Publier l'APK comme Release Gitea
Une fois le `.apk` téléchargé depuis EAS, attachez-le à une release Gitea pour le rendre disponible directement depuis le dépôt :
1. Allez sur `https://homegit.gyozamancave.fr/billisdead/Postiz-android/releases`
2. Cliquez **New Release**
3. Choisissez un tag (ex. `v1.0.0`) et un titre
4. Glissez-déposez le fichier `.apk` dans la zone de pièces jointes
5. Cliquez **Publish Release**
L'APK sera alors téléchargeable directement depuis la page du dépôt Gitea.
### Permissions Android déclarées
```xml
READ_EXTERNAL_STORAGE
WRITE_EXTERNAL_STORAGE
READ_MEDIA_IMAGES <!-- photos galerie -->
RECEIVE_BOOT_COMPLETED
VIBRATE <!-- notifications -->
```
--- ---
## Build iOS (Expo Launch) ## App Configuration
> Disponible uniquement via **Replit Expo Launch** (soumission App Store automatisée). On first launch, go to **Settings**:
1. Dans Replit, cliquez sur le bouton **Publish** 1. **Base URL**: `https://your-postiz-instance/api/public/v1`
2. Sélectionnez **Expo Launch** 2. **API Key**: generated in Postiz → Settings → API Keys
3. Suivez le wizard (compte Apple Developer requis) 3. Tap **Test Connection**, then **Save Settings**
**Note** : la publication Google Play n'est pas encore supportée par Expo Launch — utilisez EAS pour Android.
--- ---
## Pousser les modifications sur Gitea ## Postiz API
Le dépôt distant est : `ssh://gitea@homegit.gyozamancave.fr:2222/billisdead/Postiz-android.git` | Method | Endpoint | Usage |
|--------|----------|-------|
La clé SSH utilisée est stockée dans la variable d'environnement `GITEA_SSH_KEY` (côté Replit). | `GET` | `/integrations` | List channels |
| `GET` | `/posts?startDate=&endDate=` | Posts over a date range |
### Push depuis votre machine locale | `POST` | `/posts` | Create / schedule a post |
| `DELETE` | `/posts/:id` | Delete a post |
```bash | `POST` | `/upload` | Upload an image (multipart) |
# Ajouter le remote (une seule fois)
git remote add gitea ssh://gitea@homegit.gyozamancave.fr:2222/billisdead/Postiz-android.git
# Pousser
git push gitea main
```
Assurez-vous que votre clé SSH publique est ajoutée dans Gitea → *Paramètres utilisateur → SSH / GPG Keys*.
### Push depuis Replit (via script)
Depuis Replit, les commandes `git push` directes sont protégées. Utilisez le script de bundle :
```bash
# Créer le bundle
git bundle create /tmp/postiz.bundle main
# Cloner le bundle et pousser
git clone /tmp/postiz.bundle /tmp/repo_push
cd /tmp/repo_push
git remote add gitea ssh://gitea@homegit.gyozamancave.fr:2222/billisdead/Postiz-android.git
GIT_SSH_COMMAND="ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -p 2222" \
git push --force gitea main
```
--- ---
## Variables d'environnement & secrets ## Troubleshooting
| Variable | Stockage | Description | **"Not Configured" on all screens** → Settings tab → enter API key and URL → Test Connection.
|----------|----------|-------------|
| `GITEA_SSH_KEY` | Replit Secrets (shared) | Clé SSH privée pour push vers Gitea |
| `SESSION_SECRET` | Replit Secrets | Secret de session (API server) |
Les variables côté app (clé API Postiz, URL) sont **saisies par l'utilisateur** dans l'écran Paramètres et stockées dans `expo-secure-store` — elles ne transitent jamais dans le code source. **"Connection failed"** → URL must end with `/api/public/v1` — check Postiz is reachable.
--- **No notifications** → Accept permissions on first launch. Polling runs every 15 min.
## Dépannage **Build fails at Gradle** → Make sure `ANDROID_HOME` is set and Java is ≤ 24 (the script auto-detects `~/jdk21`).
### L'app affiche "Not Configured" sur tous les écrans **`expo prebuild` fails** → Run `pnpm install` from the repo root first.
→ Allez dans l'onglet **Paramètres**, entrez votre clé API et URL, puis tapez **Test Connection**.
### "Connection failed" dans les Paramètres
- Vérifiez que l'URL se termine bien par `/public/v1`
- Vérifiez que la clé API est valide (générée dans Postiz → API Keys)
- Vérifiez que votre instance Postiz est accessible depuis internet
### Pas de notifications reçues
- Acceptez les permissions de notification au premier lancement
- Le polling se fait toutes les 15 minutes — attendez un cycle complet
- Sur Android, vérifiez que les notifications de l'app ne sont pas désactivées dans les paramètres système
### Erreur Metro "module not found"
```bash
pnpm install
# Puis redémarrer le workflow Expo
```
### Le calendrier ne charge pas les posts
- Vérifiez que l'API Postiz supporte les paramètres `startDate` / `endDate` sur `GET /posts`
- Consultez les logs réseau : dans Expo Go, secouez l'appareil → *Open Debugger*
### Build EAS échoue
```bash
# Vérifier la version Expo
npx expo --version
# Vérifier la cohérence des packages
npx expo install --check
```
---
## Contribuer
1. Forkez sur Gitea : `https://homegit.gyozamancave.fr/billisdead/Postiz-android`
2. Créez une branche feature : `git checkout -b feature/ma-fonctionnalite`
3. Committez vos changements
4. Poussez et ouvrez une Pull Request
---
*Généré avec ❤️ sur Replit — PostizMobile v1.0.0*
+5 -1
View File
@@ -9,16 +9,20 @@ dist/
web-build/ web-build/
expo-env.d.ts expo-env.d.ts
# Native # Native — generated by expo prebuild, never committed
ios/ ios/
android/ android/
*.orig.* *.orig.*
*.jks *.jks
*.keystore
*.p8 *.p8
*.p12 *.p12
*.key *.key
*.mobileprovision *.mobileprovision
# Local build output
static-build/
# Metro # Metro
.metro-health-check* .metro-health-check*
+169 -308
View File
@@ -1,367 +1,228 @@
# PostizMobile # PostizMobile
Application mobile React Native (Expo) pour piloter une instance **Postiz** auto-hébergée depuis votre téléphone Android ou iOS. React Native (Expo) mobile app to control a self-hosted [Postiz](https://postiz.com) instance from Android.
Build is fully local — no expo.dev account or EAS cloud required.
--- ---
## Sommaire ## Features
1. [Fonctionnalités](#fonctionnalités) | Screen | Description |
2. [Prérequis](#prérequis) |--------|-------------|
3. [Installation & développement](#installation--développement) | **Calendar** | Monthly view with color dots per day (indigo = scheduled, green = published, red = error). Tap a day post to copy or edit it. |
4. [Configuration de l'application](#configuration-de-lapplication) | **Posts** | Filtered list (All / Queue / Published / Draft / Error) with post counts, sort toggle (newest/oldest, persisted), pull-to-refresh, swipe left to delete, swipe right to reschedule. |
5. [Architecture du projet](#architecture-du-projet) | **Compose** | Text editor with per-network character limit, channel picker, date/time picker, gallery image pick + upload, publish now or schedule. Local draft save/restore. |
6. [API Postiz utilisée](#api-postiz-utilisée) | **Settings** | API key and base URL, connection test, secure storage. 401 auto-redirect to Settings. |
7. [Build APK Android (EAS)](#build-apk-android-eas) | **Notifications** | Local alerts when a post transitions to PUBLISHED or ERROR (polling every 15 min). |
8. [Build iOS (Expo Launch)](#build-ios-expo-launch)
9. [Pousser les modifications sur Gitea](#pousser-les-modifications-sur-gitea) **Theme**: forced dark. **Auth**: API key in `expo-secure-store`, never hardcoded.
10. [Variables d'environnement & secrets](#variables-denvironnement--secrets)
11. [Dépannage](#dépannage)
--- ---
## Fonctionnalités ## Prerequisites
| Écran | Description | | Tool | Version |
|-------|-------------| |------|---------|
| **Calendrier** | Vue mensuelle avec points de couleur par jour (indigo = planifié, vert = publié, rouge = erreur). Tap sur un jour pour voir les posts. |
| **Posts** | Liste filtrée (Tous / Queue / Publié / Brouillon / Erreur) avec pull-to-refresh et swipe gauche pour supprimer. |
| **Composer** | Éditeur de texte, sélecteur de canaux, date/heure, importation d'image galerie + upload, publier maintenant ou planifier. |
| **Paramètres** | Saisie de la clé API et de l'URL de base, test de connexion, sauvegarde sécurisée (SecureStore). |
| **Notifications** | Alertes locales automatiques quand un post passe à PUBLISHED ou ERROR (polling toutes les 15 minutes). |
**Thème** : dark forcé (`userInterfaceStyle: dark`).
**Authentification** : clé API stockée dans `expo-secure-store`, jamais en dur dans le code.
---
## Prérequis
| Outil | Version minimale |
|-------|-----------------|
| Node.js | 20 LTS | | Node.js | 20 LTS |
| pnpm | 10+ | | pnpm | 10+ |
| Expo Go (téléphone) | SDK 54 compatible | | Java (JDK) | 1724 (Java 25+ not yet supported by Gradle 8) |
| Compte EAS (pour APK) | gratuit sur expo.dev | | Android SDK | see below |
```bash No expo.dev account needed for builds.
npm install -g pnpm
npm install -g eas-cli
```
--- ---
## Installation & développement ## Development
### 1. Cloner le dépôt ### Install dependencies
```bash
git clone ssh://gitea@homegit.gyozamancave.fr:2222/billisdead/Postiz-android.git
cd Postiz-android
```
### 2. Installer les dépendances
```bash ```bash
git clone https://github.com/pirona/postiz-android.git
cd postiz-android
pnpm install pnpm install
``` ```
### 3. Lancer le serveur de développement ### Start dev server (Expo Go)
```bash ```bash
pnpm --filter @workspace/postiz-mobile run dev pnpm --filter @workspace/postiz-mobile run dev
``` ```
Le terminal affiche un QR code. Scannez-le avec **Expo Go** (Android) ou l'app **Appareil photo** (iOS) pour voir l'app en direct. Scan the QR code with Expo Go on Android to preview the app live.
### 4. Ouvrir dans le navigateur (web preview)
```
http://localhost:<PORT>
```
Le port est assigné dynamiquement par l'environnement Replit.
--- ---
## Configuration de l'application ## Building an APK (local, no EAS)
Au premier lancement, l'écran **Paramètres** s'affiche car aucune clé n'est configurée. ### First-time setup
1. **URL de base** : `https://votre-instance-postiz.fr/public/v1` **1. Java 21 LTS**
2. **Clé API** : générée depuis votre instance Postiz → *Settings → API Keys*
3. Appuyez sur **Test Connection** pour valider
4. Appuyez sur **Save Settings**
La clé est chiffrée et stockée localement via `expo-secure-store`. Elle n'est jamais envoyée à un service tiers. Gradle 8 requires Java ≤ 24. If the system Java is 25+ (Fedora 44), install Temurin 21 locally:
```bash
wget -O /tmp/jdk21.tar.gz \
"https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.7%2B6/OpenJDK21U-jdk_x64_linux_hotspot_21.0.7_6.tar.gz"
mkdir -p ~/jdk21 && tar -xzf /tmp/jdk21.tar.gz -C ~/jdk21 --strip-components=1
```
`build-apk.sh` will use `~/jdk21` automatically if the system Java is ≥ 25.
**2. Android SDK**
```bash
cd artifacts/postiz-mobile
./install-android-sdk.sh
```
Add to `~/.bashrc` or `~/.zshrc`:
```bash
export ANDROID_HOME="$HOME/android-sdk"
export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/build-tools/35.0.0"
```
**2. Signing keystore**
The release keystore is stored at `~/.config/postiz-mobile/postiz-mobile.jks` (not in the repo).
To export it from EAS (one-time):
```bash
cd artifacts/postiz-mobile
eas credentials --platform android
# → Keystore: Manage everything → Download existing keystore
# Note the key alias and passwords shown during export
```
**3. Signing credentials**
```bash
cp ~/.config/postiz-mobile/signing.env.example ~/.config/postiz-mobile/signing.env
$EDITOR ~/.config/postiz-mobile/signing.env
```
Fill in:
```bash
KEYSTORE_PATH="$HOME/.config/postiz-mobile/postiz-mobile.jks"
KEYSTORE_ALIAS="<alias shown during export>"
KEYSTORE_STORE_PASSWORD="<store password>"
KEYSTORE_KEY_PASSWORD="<key password>"
```
### Build
```bash
cd artifacts/postiz-mobile
./build-apk.sh # → dist/postiz-mobile-YYYYMMDD-HHMM.apk
./build-apk.sh --aab # → dist/postiz-mobile-YYYYMMDD-HHMM.aab (Play Store)
```
The script runs `expo prebuild`, patches `android/app/build.gradle` for release signing, runs Gradle, copies the artifact to `dist/`, then wipes the credentials from `gradle.properties`.
### Install on device
```bash
adb install dist/postiz-mobile-*.apk
```
--- ---
## Architecture du projet ## How the build works
```
build-apk.sh
├── source ~/.config/postiz-mobile/signing.env
├── expo prebuild --platform android --clean
│ └── generates android/ from app.json + plugins
├── python3 patch: injects release signingConfig into build.gradle
├── append MYAPP_UPLOAD_* to gradle.properties
├── ./gradlew assembleRelease (or bundleRelease)
├── wipe signing block from gradle.properties
└── copy APK → dist/
```
The `android/` directory is not committed (gitignored). It is regenerated on each build.
---
## App configuration
On first launch, go to **Settings**:
1. **Base URL**: `https://your-postiz-instance/api/public/v1`
2. **API Key**: generated in Postiz → Settings → API Keys
3. Tap **Test Connection**, then **Save Settings**
The key is encrypted locally via `expo-secure-store` and never sent to third parties.
---
## Architecture
``` ```
artifacts/postiz-mobile/ artifacts/postiz-mobile/
├── app/ ├── app/
│ ├── _layout.tsx # Root layout : providers, fonts, notifications │ ├── _layout.tsx # Root layout: providers, fonts, 401 handler
│ └── (tabs)/ │ └── (tabs)/
│ ├── _layout.tsx # Tab bar (NativeTabs iOS 26+ / Tabs classique) │ ├── _layout.tsx # Tab bar
│ ├── index.tsx # Écran Calendrier │ ├── index.tsx # Calendar screen
│ ├── posts.tsx # Écran Liste des posts │ ├── posts.tsx # Post list screen
│ ├── compose.tsx # Écran Composer │ ├── compose.tsx # Compose screen
│ └── settings.tsx # Écran Paramètres │ └── settings.tsx # Settings screen
├── components/ ├── components/
│ ├── ChannelChip.tsx # Chip de sélection de canal │ ├── ChannelChip.tsx # Channel selector chip
│ ├── ErrorBoundary.tsx # Gestionnaire d'erreurs global │ ├── ErrorBoundary.tsx
│ ├── PostCard.tsx # Carte post avec swipe-to-delete │ ├── PostCard.tsx # Swipe-to-delete / swipe-to-reschedule
│ └── StatusBadge.tsx # Badge QUEUE / PUBLISHED / ERROR / DRAFT │ └── StatusBadge.tsx
├── constants/
│ └── colors.ts # Palette dark theme
├── context/ ├── context/
│ └── PostizContext.tsx # Client axios + SecureStore (apiKey, baseUrl) │ └── PostizContext.tsx # axios client + SecureStore + 401 interceptor
├── hooks/ ├── hooks/
│ ├── useColors.ts # Tokens couleur selon le thème │ ├── useColors.ts
│ └── useNotifications.ts # Permissions + polling + notifications locales │ └── useNotifications.ts # Permission + polling + local notifications
├── assets/ ├── lib/
│ └── images/ │ └── extractError.ts # Shared axios/fetch error formatter
│ └── icon.png # Icône générée par IA ├── build-apk.sh # Local build script
└── app.json # Config Expo (permissions, plugins, thème) └── install-android-sdk.sh # One-time Android SDK bootstrap
``` ```
### Dépendances principales ### Key dependencies
| Package | Usage | | Package | Role |
|---------|-------| |---------|------|
| `expo-router` | Navigation file-based | | `expo-router` | File-based navigation |
| `axios` | Client HTTP vers l'API Postiz | | `axios` | Postiz API HTTP client |
| `expo-secure-store` | Stockage chiffré de la clé API | | `expo-secure-store` | Encrypted key storage |
| `react-native-calendars` | Vue calendrier mensuelle | | `react-native-calendars` | Calendar view |
| `@react-native-community/datetimepicker` | Sélecteur date/heure dans Composer | | `@react-native-community/datetimepicker` | Date/time picker |
| `expo-image-picker` | Accès galerie photos | | `expo-image-picker` | Gallery access |
| `expo-notifications` | Notifications locales de statut | | `expo-notifications` | Local status notifications |
| `expo-task-manager` | Tâche de fond pour le polling | | `@tanstack/react-query` | API cache + refetch |
| `@tanstack/react-query` | Cache et refetch des données API |
--- ---
## API Postiz utilisée ## Postiz API
Base URL configurée par l'utilisateur (ex. `https://postiz.example.com/public/v1`). | Method | Endpoint | Usage |
|--------|----------|-------|
| Méthode | Endpoint | Usage | | `GET` | `/integrations` | List channels |
|---------|----------|-------| | `GET` | `/posts?startDate=&endDate=` | Posts over a date range |
| `GET` | `/integrations` | Lister les canaux (Twitter, LinkedIn, etc.) | | `POST` | `/posts` | Create / schedule a post |
| `GET` | `/posts?startDate=&endDate=` | Posts sur une plage de dates | | `DELETE` | `/posts/:id` | Delete a post |
| `POST` | `/posts` | Créer / planifier un post | | `POST` | `/upload` | Upload an image (multipart) |
| `DELETE` | `/posts/:id` | Supprimer un post |
| `POST` | `/upload` | Uploader une image (multipart) |
### Exemple de payload POST /posts
```json
{
"type": "schedule",
"date": "2025-01-15T10:00:00.000Z",
"content": [
{
"content": "Mon super post 🚀",
"image": [{ "id": "upload-id", "path": "/uploads/photo.jpg" }]
}
],
"integrations": ["integration-id-twitter", "integration-id-linkedin"]
}
```
Pour publier immédiatement, utilisez `"type": "now"`.
--- ---
## Build APK Android (EAS) ## Troubleshooting
> **Prérequis** : compte gratuit sur [expo.dev](https://expo.dev) et `eas-cli` installé. **"Not Configured" on all screens** → Settings tab → enter API key and URL → Test Connection.
### 1. Se connecter à EAS **"Connection failed"** → URL must end with `/api/public/v1` — check Postiz is reachable.
```bash **No notifications** → Accept permissions on first launch. Polling runs every 15 min.
npx eas login
```
### 2. Initialiser EAS dans le projet **Build fails at Gradle** → Make sure `ANDROID_HOME` is set and `./gradlew` is executable (`chmod +x android/gradlew`).
```bash **`expo prebuild` fails** → Run `pnpm install` from the repo root first.
cd artifacts/postiz-mobile
npx eas init
```
Cela génère un `projectId` dans `app.json`.
### 3. Créer le fichier de configuration EAS
Créez `artifacts/postiz-mobile/eas.json` :
```json
{
"cli": {
"version": ">= 16.0.0"
},
"build": {
"preview": {
"android": {
"buildType": "apk"
}
},
"production": {
"android": {
"buildType": "app-bundle"
}
}
},
"submit": {
"production": {}
}
}
```
### 4. Lancer le build APK
```bash
# APK de test (sideload)
npx eas build --platform android --profile preview
# AAB pour le Play Store
npx eas build --platform android --profile production
```
Le build se fait dans le cloud EAS. Vous recevez un lien de téléchargement à la fin (~10-15 min).
### 5. Installer l'APK sur votre téléphone
```bash
# Via adb
adb install postiz-mobile.apk
# Ou scannez le QR code affiché par EAS
```
### Permissions Android déclarées
```xml
READ_EXTERNAL_STORAGE
WRITE_EXTERNAL_STORAGE
READ_MEDIA_IMAGES <!-- photos galerie -->
RECEIVE_BOOT_COMPLETED
VIBRATE <!-- notifications -->
```
---
## Build iOS (Expo Launch)
> Disponible uniquement via **Replit Expo Launch** (soumission App Store automatisée).
1. Dans Replit, cliquez sur le bouton **Publish**
2. Sélectionnez **Expo Launch**
3. Suivez le wizard (compte Apple Developer requis)
**Note** : la publication Google Play n'est pas encore supportée par Expo Launch — utilisez EAS pour Android.
---
## Pousser les modifications sur Gitea
Le dépôt distant est : `ssh://gitea@homegit.gyozamancave.fr:2222/billisdead/Postiz-android.git`
La clé SSH utilisée est stockée dans la variable d'environnement `GITEA_SSH_KEY` (côté Replit).
### Push depuis votre machine locale
```bash
# Ajouter le remote (une seule fois)
git remote add gitea ssh://gitea@homegit.gyozamancave.fr:2222/billisdead/Postiz-android.git
# Pousser
git push gitea main
```
Assurez-vous que votre clé SSH publique est ajoutée dans Gitea → *Paramètres utilisateur → SSH / GPG Keys*.
### Push depuis Replit (via script)
Depuis Replit, les commandes `git push` directes sont protégées. Utilisez le script de bundle :
```bash
# Créer le bundle
git bundle create /tmp/postiz.bundle main
# Cloner le bundle et pousser
git clone /tmp/postiz.bundle /tmp/repo_push
cd /tmp/repo_push
git remote add gitea ssh://gitea@homegit.gyozamancave.fr:2222/billisdead/Postiz-android.git
GIT_SSH_COMMAND="ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -p 2222" \
git push --force gitea main
```
---
## Variables d'environnement & secrets
| Variable | Stockage | Description |
|----------|----------|-------------|
| `GITEA_SSH_KEY` | Replit Secrets (shared) | Clé SSH privée pour push vers Gitea |
| `SESSION_SECRET` | Replit Secrets | Secret de session (API server) |
Les variables côté app (clé API Postiz, URL) sont **saisies par l'utilisateur** dans l'écran Paramètres et stockées dans `expo-secure-store` — elles ne transitent jamais dans le code source.
---
## Dépannage
### L'app affiche "Not Configured" sur tous les écrans
→ Allez dans l'onglet **Paramètres**, entrez votre clé API et URL, puis tapez **Test Connection**.
### "Connection failed" dans les Paramètres
- Vérifiez que l'URL se termine bien par `/public/v1`
- Vérifiez que la clé API est valide (générée dans Postiz → API Keys)
- Vérifiez que votre instance Postiz est accessible depuis internet
### Pas de notifications reçues
- Acceptez les permissions de notification au premier lancement
- Le polling se fait toutes les 15 minutes — attendez un cycle complet
- Sur Android, vérifiez que les notifications de l'app ne sont pas désactivées dans les paramètres système
### Erreur Metro "module not found"
```bash
pnpm install
# Puis redémarrer le workflow Expo
```
### Le calendrier ne charge pas les posts
- Vérifiez que l'API Postiz supporte les paramètres `startDate` / `endDate` sur `GET /posts`
- Consultez les logs réseau : dans Expo Go, secouez l'appareil → *Open Debugger*
### Build EAS échoue
```bash
# Vérifier la version Expo
npx expo --version
# Vérifier la cohérence des packages
npx expo install --check
```
---
## Contribuer
1. Forkez sur Gitea : `https://homegit.gyozamancave.fr/billisdead/Postiz-android`
2. Créez une branche feature : `git checkout -b feature/ma-fonctionnalite`
3. Committez vos changements
4. Poussez et ouvrez une Pull Request
---
*Généré avec ❤️ sur Replit — PostizMobile v1.0.0*
+201 -21
View File
@@ -1,8 +1,10 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import DateTimePicker from "@react-native-community/datetimepicker"; import DateTimePicker from "@react-native-community/datetimepicker";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
import { Image } from "expo-image"; import { Image } from "expo-image";
import * as ImageManipulator from "expo-image-manipulator";
import * as ImagePicker from "expo-image-picker"; import * as ImagePicker from "expo-image-picker";
import { fetch as expoFetch } from "expo/fetch"; import { fetch as expoFetch } from "expo/fetch";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
@@ -24,16 +26,29 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ChannelChip } from "@/components/ChannelChip"; import { ChannelChip } from "@/components/ChannelChip";
import { PostizIntegration, PostizUploadResult, usePostiz } from "@/context/PostizContext"; import { PostizIntegration, PostizUploadResult, usePostiz } from "@/context/PostizContext";
import { useColors } from "@/hooks/useColors"; import { useColors } from "@/hooks/useColors";
const DRAFT_STORAGE_KEY = "postiz_local_draft";
const NETWORK_CHAR_LIMITS: Record<string, number> = {
twitter: 280, x: 280,
instagram: 2200,
linkedin: 3000,
facebook: 63206,
youtube: 5000,
tiktok: 2200,
};
export default function ComposeScreen() { export default function ComposeScreen() {
const colors = useColors(); const colors = useColors();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { client, isConfigured, apiKey, baseUrl } = usePostiz(); const { client, isConfigured, apiKey, baseUrl } = usePostiz();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { prefillContent, prefillIntegrationIds } = useLocalSearchParams<{ const { prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId } =
prefillContent?: string; useLocalSearchParams<{
prefillIntegrationIds?: string; prefillContent?: string;
}>(); prefillIntegrationIds?: string;
prefillImagePath?: string;
prefillImageId?: string;
}>();
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [selectedChannels, setSelectedChannels] = useState<string[]>([]); const [selectedChannels, setSelectedChannels] = useState<string[]>([]);
const [postNow, setPostNow] = useState(false); const [postNow, setPostNow] = useState(false);
@@ -43,15 +58,34 @@ export default function ComposeScreen() {
const [showDatePicker, setShowDatePicker] = useState(false); const [showDatePicker, setShowDatePicker] = useState(false);
const [showTimePicker, setShowTimePicker] = useState(false); const [showTimePicker, setShowTimePicker] = useState(false);
const [imageUri, setImageUri] = useState<string | null>(null); const [imageUri, setImageUri] = useState<string | null>(null);
const [existingMedia, setExistingMedia] = useState<Array<{ id: string; path: string }>>([]);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [draftBanner, setDraftBanner] = useState(false);
useEffect(() => { useEffect(() => {
if (prefillContent) setContent(String(prefillContent)); if (prefillContent) {
setContent(String(prefillContent));
}
if (prefillIntegrationIds) { if (prefillIntegrationIds) {
setSelectedChannels(String(prefillIntegrationIds).split(",").filter(Boolean)); setSelectedChannels(String(prefillIntegrationIds).split(",").filter(Boolean));
} }
}, [prefillContent, prefillIntegrationIds]); if (prefillImagePath && prefillImageId) {
setExistingMedia([{ id: String(prefillImageId), path: String(prefillImagePath) }]);
setImageUri(String(prefillImagePath));
}
}, [prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId]);
useEffect(() => {
if (prefillContent) return;
AsyncStorage.getItem(DRAFT_STORAGE_KEY).then((raw) => {
if (!raw) return;
try {
const draft = JSON.parse(raw);
if (draft?.content) setDraftBanner(true);
} catch {}
});
}, [prefillContent]);
const { data: integrations, isLoading: loadingIntegrations } = const { data: integrations, isLoading: loadingIntegrations } =
useQuery<PostizIntegration[]>({ useQuery<PostizIntegration[]>({
@@ -65,6 +99,39 @@ export default function ComposeScreen() {
staleTime: 60000, staleTime: 60000,
}); });
const effectiveCharLimit = (() => {
if (selectedChannels.length === 0 || !integrations) return 3000;
const selected = integrations.filter((i) => selectedChannels.includes(i.id));
const limits = selected.map((i) => {
const t = (i.type ?? i.internalType ?? "").toLowerCase();
for (const [key, limit] of Object.entries(NETWORK_CHAR_LIMITS)) {
if (t.includes(key)) return limit;
}
return 3000;
});
return Math.min(...limits);
})();
const saveDraft = async () => {
const draft = { content, integrationIds: selectedChannels };
await AsyncStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft));
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
Alert.alert("Draft saved", "Your draft has been saved locally.");
};
const restoreDraft = async () => {
const raw = await AsyncStorage.getItem(DRAFT_STORAGE_KEY);
if (!raw) return;
try {
const draft = JSON.parse(raw);
if (draft.content) setContent(draft.content);
if (draft.integrationIds?.length) setSelectedChannels(draft.integrationIds);
setDraftBanner(false);
} catch {}
};
const dismissDraft = () => setDraftBanner(false);
const toggleChannel = (id: string) => { const toggleChannel = (id: string) => {
setSelectedChannels((prev) => setSelectedChannels((prev) =>
prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id] prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]
@@ -80,14 +147,33 @@ export default function ComposeScreen() {
const result = await ImagePicker.launchImageLibraryAsync({ const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"], mediaTypes: ["images"],
allowsEditing: false, allowsEditing: false,
quality: 0.85, quality: 1,
}); });
if (!result.canceled && result.assets[0]) { if (!result.canceled && result.assets[0]) {
setImageUri(result.assets[0].uri); const asset = result.assets[0];
const MAX_DIM = 1920;
const w = asset.width ?? 0;
const h = asset.height ?? 0;
const needsResize = w > MAX_DIM || h > MAX_DIM;
if (needsResize) {
const landscape = w >= h;
const resized = await ImageManipulator.manipulateAsync(
asset.uri,
[{ resize: landscape ? { width: MAX_DIM } : { height: MAX_DIM } }],
{ compress: 0.85, format: ImageManipulator.SaveFormat.JPEG }
);
setImageUri(resized.uri);
} else {
setImageUri(asset.uri);
}
setExistingMedia([]);
} }
}; };
const removeImage = () => setImageUri(null); const removeImage = () => {
setImageUri(null);
setExistingMedia([]);
};
const uploadImage = async (): Promise<PostizUploadResult> => { const uploadImage = async (): Promise<PostizUploadResult> => {
setUploading(true); setUploading(true);
@@ -134,21 +220,27 @@ export default function ComposeScreen() {
setSubmitting(true); setSubmitting(true);
try { try {
let media: Array<{ id: string; path: string }> = []; let media: Array<{ id: string; path: string }> = [];
if (imageUri) { const isLocalFile = imageUri && !imageUri.startsWith("http");
if (imageUri && isLocalFile) {
const uploaded = await uploadImage(); const uploaded = await uploadImage();
media = [{ id: uploaded.id, path: uploaded.path }]; media = [{ id: uploaded.id, path: uploaded.path }];
} else if (existingMedia.length > 0) {
media = existingMedia;
} }
const payload = { const payload = {
type: postNow ? "now" : "schedule", type: postNow ? "now" : "schedule",
date: postNow ? new Date().toISOString() : scheduleDate.toISOString(), date: postNow ? new Date().toISOString() : scheduleDate.toISOString(),
shortLink: false, shortLink: false,
tags: [] as string[], tags: [] as string[],
posts: selectedChannels.map((integrationId) => ({ posts: selectedChannels.map((integrationId) => {
integration: { id: integrationId }, return {
value: [{ content: content.trim(), id: "", image: media }], integration: { id: integrationId },
})), value: [{ content: content.trim(), image: media }],
};
}),
}; };
const body = JSON.stringify(payload); const body = JSON.stringify(payload);
console.log("[compose] POST", `${baseUrl}/posts`, body);
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
const res = await globalThis.fetch(`${baseUrl}/posts`, { const res = await globalThis.fetch(`${baseUrl}/posts`, {
@@ -164,7 +256,8 @@ export default function ComposeScreen() {
let detail = ""; let detail = "";
try { try {
const raw = await res.text(); const raw = await res.text();
detail = raw.slice(0, 300); console.log("[compose] 400 body:", raw);
detail = raw.slice(0, 500);
} catch { } catch {
detail = res.statusText; detail = res.statusText;
} }
@@ -172,6 +265,7 @@ export default function ComposeScreen() {
} }
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
await AsyncStorage.removeItem(DRAFT_STORAGE_KEY);
Alert.alert( Alert.alert(
"Posted!", "Posted!",
postNow ? "Your post has been published." : "Post scheduled successfully.", postNow ? "Your post has been published." : "Post scheduled successfully.",
@@ -193,6 +287,8 @@ export default function ComposeScreen() {
setSelectedChannels([]); setSelectedChannels([]);
setPostNow(false); setPostNow(false);
setImageUri(null); setImageUri(null);
setExistingMedia([]);
setDraftBanner(false);
setScheduleDate(new Date(Date.now() + 60 * 60 * 1000)); setScheduleDate(new Date(Date.now() + 60 * 60 * 1000));
}; };
@@ -234,6 +330,21 @@ export default function ComposeScreen() {
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{draftBanner && (
<View style={[styles.draftBanner, { backgroundColor: colors.card, borderColor: colors.border }]}>
<Feather name="file-text" size={14} color={colors.primary} />
<Text style={[styles.draftBannerText, { color: colors.foreground }]}>
You have a saved draft
</Text>
<TouchableOpacity onPress={restoreDraft} activeOpacity={0.7}>
<Text style={[styles.draftBannerAction, { color: colors.primary }]}>Restore</Text>
</TouchableOpacity>
<TouchableOpacity onPress={dismissDraft} activeOpacity={0.7}>
<Feather name="x" size={14} color={colors.mutedForeground} />
</TouchableOpacity>
</View>
)}
<View <View
style={[ style={[
styles.textArea, styles.textArea,
@@ -247,12 +358,31 @@ export default function ComposeScreen() {
multiline multiline
value={content} value={content}
onChangeText={setContent} onChangeText={setContent}
maxLength={3000} maxLength={effectiveCharLimit}
textAlignVertical="top" textAlignVertical="top"
/> />
<Text style={[styles.charCount, { color: colors.mutedForeground }]}> <View style={styles.charCountRow}>
{content.length}/3000 {effectiveCharLimit < 3000 && selectedChannels.length > 0 && (
</Text> <Text style={[styles.charCountLabel, { color: colors.mutedForeground }]}>
limit: {effectiveCharLimit}
</Text>
)}
<Text
style={[
styles.charCount,
{
color:
content.length >= effectiveCharLimit
? colors.error
: content.length > effectiveCharLimit * 0.9
? colors.warning
: colors.mutedForeground,
},
]}
>
{content.length}/{effectiveCharLimit}
</Text>
</View>
</View> </View>
{imageUri && ( {imageUri && (
@@ -406,6 +536,16 @@ export default function ComposeScreen() {
/> />
)} )}
<TouchableOpacity
onPress={saveDraft}
activeOpacity={0.7}
disabled={submitting || uploading || !content.trim()}
style={[styles.draftBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
>
<Feather name="file-text" size={14} color={colors.mutedForeground} />
<Text style={[styles.draftBtnText, { color: colors.mutedForeground }]}>Save Draft</Text>
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
onPress={handleSubmit} onPress={handleSubmit}
activeOpacity={0.85} activeOpacity={0.85}
@@ -460,11 +600,51 @@ const styles = StyleSheet.create({
lineHeight: 22, lineHeight: 22,
minHeight: 100, minHeight: 100,
}, },
charCountRow: {
flexDirection: "row",
justifyContent: "flex-end",
alignItems: "center",
gap: 6,
marginTop: 4,
},
charCountLabel: {
fontSize: 10,
fontFamily: "Inter_400Regular",
},
charCount: { charCount: {
fontSize: 11, fontSize: 11,
fontFamily: "Inter_400Regular", fontFamily: "Inter_400Regular",
alignSelf: "flex-end", },
marginTop: 4, draftBanner: {
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 12,
borderWidth: 1,
},
draftBannerText: {
flex: 1,
fontSize: 13,
fontFamily: "Inter_400Regular",
},
draftBannerAction: {
fontSize: 13,
fontFamily: "Inter_600SemiBold",
},
draftBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 6,
paddingVertical: 10,
borderRadius: 12,
borderWidth: 1,
},
draftBtnText: {
fontSize: 13,
fontFamily: "Inter_500Medium",
}, },
imagePreviewWrap: { imagePreviewWrap: {
position: "relative", position: "relative",
+38 -19
View File
@@ -1,10 +1,12 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import axios from "axios"; import * as Clipboard from "expo-clipboard";
import * as Haptics from "expo-haptics";
import { router } from "expo-router"; import { router } from "expo-router";
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Alert,
FlatList, FlatList,
Platform, Platform,
StyleSheet, StyleSheet,
@@ -17,24 +19,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { PostizPost, usePostiz } from "@/context/PostizContext"; import { PostizPost, usePostiz } from "@/context/PostizContext";
import { useColors } from "@/hooks/useColors"; import { useColors } from "@/hooks/useColors";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
import { extractError } from "@/lib/extractError";
function extractError(err: unknown): string {
if (axios.isAxiosError(err)) {
const status = err.response?.status;
const data = err.response?.data;
if (data) {
const body =
typeof data === "string"
? data.slice(0, 200)
: (data?.message ?? data?.error ?? JSON.stringify(data)).toString().slice(0, 200);
return status ? `HTTP ${status}: ${body}` : body;
}
if (status) return `HTTP ${status}${err.message}`;
if (err.message) return err.message;
}
if (err instanceof Error) return err.message;
return "Unknown error";
}
function formatDate(date: Date): string { function formatDate(date: Date): string {
const y = date.getFullYear(); const y = date.getFullYear();
@@ -65,6 +50,39 @@ export default function CalendarScreen() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { client, isConfigured } = usePostiz(); const { client, isConfigured } = usePostiz();
const showContextMenu = (post: PostizPost) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
const preview = post.content.slice(0, 60) + (post.content.length > 60 ? "…" : "");
const integrations = post.integrations ?? (post.integration ? [post.integration] : []);
Alert.alert(
post.state === "PUBLISHED" ? "Published post" :
post.state === "QUEUE" ? "Scheduled post" :
post.state === "ERROR" ? "Failed post" : "Draft",
preview,
[
{
text: "Copy text",
onPress: () => {
Clipboard.setStringAsync(post.content);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
},
},
{
text: post.state === "PUBLISHED" ? "Repost" : "Edit",
onPress: () =>
router.push({
pathname: "/(tabs)/compose",
params: {
prefillContent: post.content,
prefillIntegrationIds: integrations.map((i) => i.id).join(","),
},
}),
},
{ text: "Cancel", style: "cancel" },
]
);
};
const now = new Date(); const now = new Date();
const [currentMonth, setCurrentMonth] = useState({ const [currentMonth, setCurrentMonth] = useState({
year: now.getFullYear(), year: now.getFullYear(),
@@ -255,6 +273,7 @@ export default function CalendarScreen() {
<TouchableOpacity <TouchableOpacity
style={[styles.dayPost, { borderBottomColor: colors.border }]} style={[styles.dayPost, { borderBottomColor: colors.border }]}
activeOpacity={0.7} activeOpacity={0.7}
onPress={() => showContextMenu(item)}
> >
<View style={styles.dayPostLeft}> <View style={styles.dayPostLeft}>
<Text style={[styles.timeText, { color: colors.primary }]}> <Text style={[styles.timeText, { color: colors.primary }]}>
+129 -63
View File
@@ -1,11 +1,11 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import DateTimePicker from "@react-native-community/datetimepicker"; import DateTimePicker from "@react-native-community/datetimepicker";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios"; import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import React, { useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
@@ -21,24 +21,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { PostCard } from "@/components/PostCard"; import { PostCard } from "@/components/PostCard";
import { PostizPost, usePostiz } from "@/context/PostizContext"; import { PostizPost, usePostiz } from "@/context/PostizContext";
import { useColors } from "@/hooks/useColors"; import { useColors } from "@/hooks/useColors";
import { extractError } from "@/lib/extractError";
function extractError(err: unknown): string { const SORT_STORAGE_KEY = "postiz_posts_sort";
if (axios.isAxiosError(err)) {
const status = err.response?.status;
const data = err.response?.data;
if (data) {
const body =
typeof data === "string"
? data.slice(0, 200)
: (data?.message ?? data?.error ?? JSON.stringify(data)).toString().slice(0, 200);
return status ? `HTTP ${status}: ${body}` : body;
}
if (status) return `HTTP ${status}${err.message}`;
if (err.message) return err.message;
}
if (err instanceof Error) return err.message;
return "Unknown error";
}
type FilterType = "all" | "QUEUE" | "PUBLISHED" | "ERROR" | "DRAFT"; type FilterType = "all" | "QUEUE" | "PUBLISHED" | "ERROR" | "DRAFT";
@@ -57,7 +42,21 @@ export default function PostsScreen() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const router = useRouter(); const router = useRouter();
const [filter, setFilter] = useState<FilterType>("all"); const [filter, setFilter] = useState<FilterType>("all");
const [sortOrder, setSortOrder] = useState<"desc" | "asc">("desc");
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [copyToast, setCopyToast] = useState(false);
useEffect(() => {
AsyncStorage.getItem(SORT_STORAGE_KEY).then((v) => {
if (v === "asc" || v === "desc") setSortOrder(v);
});
}, []);
const toggleSort = () => {
const next = sortOrder === "desc" ? "asc" : "desc";
setSortOrder(next);
AsyncStorage.setItem(SORT_STORAGE_KEY, next);
};
// reschedule state // reschedule state
const [reschedulePost, setReschedulePost] = useState<PostizPost | null>(null); const [reschedulePost, setReschedulePost] = useState<PostizPost | null>(null);
@@ -86,10 +85,27 @@ export default function PostsScreen() {
staleTime: 0, staleTime: 0,
}); });
const filteredPosts = const filteredPosts = useMemo(() => {
filter === "all" const list =
? posts ?? [] filter === "all"
: (posts ?? []).filter((p) => p.state === filter); ? posts ?? []
: (posts ?? []).filter((p) => p.state === filter);
return [...list].sort((a, b) => {
const diff = new Date(a.publishDate).getTime() - new Date(b.publishDate).getTime();
return sortOrder === "desc" ? -diff : diff;
});
}, [posts, filter, sortOrder]);
const filterCounts = useMemo(() => {
const all = posts ?? [];
return {
all: all.length,
QUEUE: all.filter((p) => p.state === "QUEUE").length,
PUBLISHED: all.filter((p) => p.state === "PUBLISHED").length,
DRAFT: all.filter((p) => p.state === "DRAFT").length,
ERROR: all.filter((p) => p.state === "ERROR").length,
};
}, [posts]);
const handleRefresh = async () => { const handleRefresh = async () => {
setRefreshing(true); setRefreshing(true);
@@ -152,8 +168,19 @@ export default function PostsScreen() {
const submitReschedule = async (post: PostizPost, date: Date) => { const submitReschedule = async (post: PostizPost, date: Date) => {
if (!client) return; if (!client) return;
const integrations = post.integrations ?? (post.integration ? [post.integration] : []);
try { try {
await client.put(`posts/${post.id}`, { date: date.toISOString() }); await client.delete(`posts/${post.id}`);
await client.post("posts", {
type: "schedule",
date: date.toISOString(),
shortLink: false,
tags: [] as string[],
posts: integrations.map((intg) => ({
integration: { id: intg.id },
value: [{ content: post.content, image: post.image ?? [] }],
})),
});
queryClient.invalidateQueries({ queryKey: ["posts-list"] }); queryClient.invalidateQueries({ queryKey: ["posts-list"] });
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
Alert.alert("Rescheduled", `Post moved to ${date.toLocaleDateString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}`); Alert.alert("Rescheduled", `Post moved to ${date.toLocaleDateString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}`);
@@ -172,7 +199,12 @@ export default function PostsScreen() {
buttons.push({ buttons.push({
text: "Copy text", text: "Copy text",
onPress: () => Clipboard.setStringAsync(post.content), onPress: async () => {
await Clipboard.setStringAsync(post.content);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
setCopyToast(true);
setTimeout(() => setCopyToast(false), 2000);
},
}); });
if (post.state === "ERROR") { if (post.state === "ERROR") {
@@ -233,43 +265,61 @@ export default function PostsScreen() {
}, },
]} ]}
> >
<FlatList <View style={[styles.filterRow, { borderBottomColor: colors.border }]}>
horizontal <FlatList
data={FILTERS} horizontal
keyExtractor={(item) => item.key} data={FILTERS}
showsHorizontalScrollIndicator={false} keyExtractor={(item) => item.key}
contentContainerStyle={styles.filterList} showsHorizontalScrollIndicator={false}
renderItem={({ item }) => ( contentContainerStyle={styles.filterList}
<TouchableOpacity renderItem={({ item }) => {
onPress={() => setFilter(item.key)} const count = posts ? filterCounts[item.key] : undefined;
activeOpacity={0.7} const active = filter === item.key;
style={[ return (
styles.filterChip, <TouchableOpacity
{ onPress={() => setFilter(item.key)}
backgroundColor: activeOpacity={0.7}
filter === item.key ? colors.primary : colors.secondary, style={[
borderColor: styles.filterChip,
filter === item.key ? colors.primary : colors.border, {
}, backgroundColor: active ? colors.primary : colors.secondary,
]} borderColor: active ? colors.primary : colors.border,
> },
<Text ]}
style={[ >
styles.filterText, <Text
{ style={[
color: styles.filterText,
filter === item.key { color: active ? colors.primaryForeground : colors.mutedForeground },
? colors.primaryForeground ]}
: colors.mutedForeground, >
}, {item.label}
]} {count !== undefined && count > 0 ? ` ${count}` : ""}
> </Text>
{item.label} </TouchableOpacity>
</Text> );
</TouchableOpacity> }}
)} style={styles.filterBar}
style={[styles.filterBar, { borderBottomColor: colors.border }]} />
/> <TouchableOpacity
onPress={toggleSort}
activeOpacity={0.7}
style={[styles.sortBtn, { borderColor: colors.border, backgroundColor: colors.secondary }]}
>
<Feather
name={sortOrder === "desc" ? "arrow-down" : "arrow-up"}
size={14}
color={colors.mutedForeground}
/>
</TouchableOpacity>
</View>
{copyToast && (
<View style={[styles.toast, { backgroundColor: colors.success }]}>
<Feather name="check" size={13} color="#fff" />
<Text style={styles.toastText}>Copied</Text>
</View>
)}
{isLoading ? ( {isLoading ? (
<View style={styles.centered}> <View style={styles.centered}>
@@ -302,6 +352,7 @@ export default function PostsScreen() {
post={item} post={item}
onDelete={handleDelete} onDelete={handleDelete}
onLongPress={showContextMenu} onLongPress={showContextMenu}
onReschedule={startReschedule}
/> />
)} )}
refreshControl={ refreshControl={
@@ -373,13 +424,28 @@ const styles = StyleSheet.create({
gap: 10, gap: 10,
paddingHorizontal: 32, paddingHorizontal: 32,
}, },
filterBar: { borderBottomWidth: StyleSheet.hairlineWidth, flexGrow: 0 }, filterRow: { flexDirection: "row", alignItems: "center", borderBottomWidth: StyleSheet.hairlineWidth },
filterBar: { flex: 1 },
filterList: { paddingHorizontal: 16, paddingVertical: 10, gap: 8 }, filterList: { paddingHorizontal: 16, paddingVertical: 10, gap: 8 },
filterChip: { paddingHorizontal: 14, paddingVertical: 6, borderRadius: 20, borderWidth: 1 }, filterChip: { paddingHorizontal: 14, paddingVertical: 6, borderRadius: 20, borderWidth: 1 },
sortBtn: { marginRight: 12, padding: 7, borderRadius: 8, borderWidth: 1 },
filterText: { fontSize: 13, fontFamily: "Inter_500Medium" }, filterText: { fontSize: 13, fontFamily: "Inter_500Medium" },
emptyState: { alignItems: "center", paddingTop: 64, gap: 10 }, emptyState: { alignItems: "center", paddingTop: 64, gap: 10 },
emptyTitle: { fontSize: 18, fontFamily: "Inter_600SemiBold" }, emptyTitle: { fontSize: 18, fontFamily: "Inter_600SemiBold" },
emptyText: { fontSize: 14, fontFamily: "Inter_400Regular", textAlign: "center" }, emptyText: { fontSize: 14, fontFamily: "Inter_400Regular", textAlign: "center" },
retryBtn: { marginTop: 4, paddingHorizontal: 20, paddingVertical: 10, borderRadius: 10 }, retryBtn: { marginTop: 4, paddingHorizontal: 20, paddingVertical: 10, borderRadius: 10 },
retryText: { fontSize: 14, fontFamily: "Inter_600SemiBold" }, retryText: { fontSize: 14, fontFamily: "Inter_600SemiBold" },
toast: {
position: "absolute",
bottom: 100,
alignSelf: "center",
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
zIndex: 999,
},
toastText: { fontSize: 13, fontFamily: "Inter_600SemiBold", color: "#fff" },
}); });
@@ -15,29 +15,9 @@ import {
} from "react-native"; } from "react-native";
import { KeyboardAwareScrollView } from "react-native-keyboard-controller"; import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { usePostiz } from "@/context/PostizContext"; import { usePostiz, DEFAULT_BASE_URL } from "@/context/PostizContext";
import { useColors } from "@/hooks/useColors"; import { useColors } from "@/hooks/useColors";
import { extractError } from "@/lib/extractError";
const DEFAULT_BASE_URL = "https://postiz.gyozamancave.fr/api/public/v1";
function extractAxiosError(err: unknown): string {
if (axios.isAxiosError(err)) {
const status = err.response?.status;
const data = err.response?.data;
if (data) {
const body =
typeof data === "string"
? data.slice(0, 200)
: (data?.message ?? data?.error ?? JSON.stringify(data)).toString().slice(0, 200);
return status ? `HTTP ${status}: ${body}` : body;
}
if (status) return `HTTP ${status}${err.message}`;
if (err.code === "ECONNABORTED") return "Request timed out (10s). Check that the URL is reachable.";
if (err.message) return err.message;
}
if (err instanceof Error) return err.message;
return "Unknown error";
}
export default function SettingsScreen() { export default function SettingsScreen() {
const colors = useColors(); const colors = useColors();
@@ -98,7 +78,7 @@ export default function SettingsScreen() {
continue; continue;
} }
} }
lastError = extractAxiosError(err); lastError = extractError(err);
} }
} }
@@ -119,7 +99,7 @@ export default function SettingsScreen() {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
Alert.alert("Saved", "Settings saved successfully."); Alert.alert("Saved", "Settings saved successfully.");
} catch (err: unknown) { } catch (err: unknown) {
Alert.alert("Error", `Failed to save settings.\n${extractAxiosError(err)}`); Alert.alert("Error", `Failed to save settings.\n${extractError(err)}`);
} finally { } finally {
setSaving(false); setSaving(false);
} }
+26 -2
View File
@@ -6,15 +6,16 @@ import {
useFonts, useFonts,
} from "@expo-google-fonts/inter"; } from "@expo-google-fonts/inter";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Stack } from "expo-router"; import { router, Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen"; import * as SplashScreen from "expo-splash-screen";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { Alert } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller"; import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context"; import { SafeAreaProvider } from "react-native-safe-area-context";
import { ErrorBoundary } from "@/components/ErrorBoundary"; import { ErrorBoundary } from "@/components/ErrorBoundary";
import { PostizProvider } from "@/context/PostizContext"; import { PostizProvider, usePostiz } from "@/context/PostizContext";
import { useNotifications } from "@/hooks/useNotifications"; import { useNotifications } from "@/hooks/useNotifications";
SplashScreen.preventAutoHideAsync(); SplashScreen.preventAutoHideAsync();
@@ -33,6 +34,28 @@ function NotificationBootstrap() {
return null; return null;
} }
function UnauthorizedHandler() {
const { unauthorized, clearUnauthorized } = usePostiz();
useEffect(() => {
if (!unauthorized) return;
Alert.alert(
"API key invalid",
"Your API key was rejected (401). Update it in Settings.",
[
{
text: "Open Settings",
onPress: () => {
clearUnauthorized();
router.push("/(tabs)/settings");
},
},
{ text: "Dismiss", style: "cancel", onPress: clearUnauthorized },
]
);
}, [unauthorized, clearUnauthorized]);
return null;
}
function RootLayoutNav() { function RootLayoutNav() {
return ( return (
<Stack screenOptions={{ headerBackTitle: "Back" }}> <Stack screenOptions={{ headerBackTitle: "Back" }}>
@@ -63,6 +86,7 @@ export default function RootLayout() {
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<PostizProvider> <PostizProvider>
<NotificationBootstrap /> <NotificationBootstrap />
<UnauthorizedHandler />
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider> <KeyboardProvider>
<RootLayoutNav /> <RootLayoutNav />
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
# build-apk.sh — Build a signed release APK locally without expo.dev / EAS.
#
# Prerequisites (first time only):
# 1. Export EAS keystore:
# cd artifacts/postiz-mobile
# eas credentials --platform android
# → "Download existing keystore" → save to ~/.config/postiz-mobile/postiz-mobile.jks
# 2. Fill in signing credentials:
# cp ~/.config/postiz-mobile/signing.env.example ~/.config/postiz-mobile/signing.env
# $EDITOR ~/.config/postiz-mobile/signing.env
# 3. Install Android SDK (if not already):
# ./install-android-sdk.sh
# # then add ANDROID_HOME to your shell profile and reload it
#
# Usage:
# ./build-apk.sh # → dist/postiz-mobile-YYYYMMDD-HHMM.apk
# ./build-apk.sh --aab # → dist/postiz-mobile-YYYYMMDD-HHMM.aab (Play Store)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SIGNING_ENV="$HOME/.config/postiz-mobile/signing.env"
DIST_DIR="$SCRIPT_DIR/dist"
BUILD_TYPE="${1:-}"
# ─── Colours ───────────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
info() { echo -e "${GREEN}[build]${NC} $*"; }
warn() { echo -e "${YELLOW}[warn]${NC} $*"; }
abort() { echo -e "${RED}[error]${NC} $*" >&2; exit 1; }
# ─── 1. Signing credentials ────────────────────────────────────────────────
if [ ! -f "$SIGNING_ENV" ]; then
abort "Missing signing.env.\n\n cp ~/.config/postiz-mobile/signing.env.example ~/.config/postiz-mobile/signing.env\n # then fill in your keystore path and passwords"
fi
# shellcheck source=/dev/null
source "$SIGNING_ENV"
[ -z "${KEYSTORE_PATH:-}" ] && abort "KEYSTORE_PATH not set in signing.env"
[ -z "${KEYSTORE_ALIAS:-}" ] && abort "KEYSTORE_ALIAS not set in signing.env"
[ -z "${KEYSTORE_STORE_PASSWORD:-}" ] && abort "KEYSTORE_STORE_PASSWORD not set in signing.env"
[ -z "${KEYSTORE_KEY_PASSWORD:-}" ] && abort "KEYSTORE_KEY_PASSWORD not set in signing.env"
KEYSTORE_PATH_EXPANDED="${KEYSTORE_PATH/#\$HOME/$HOME}"
KEYSTORE_PATH_EXPANDED="${KEYSTORE_PATH_EXPANDED/#~/$HOME}"
[ ! -f "$KEYSTORE_PATH_EXPANDED" ] && \
abort "Keystore not found: $KEYSTORE_PATH_EXPANDED\n\nExport it from EAS:\n eas credentials --platform android\n → Download existing keystore → save to $KEYSTORE_PATH_EXPANDED"
# ─── 2. Java 17/21 — Gradle requires Java ≤ 24 (system Java 25 is too new) ──
# Use ~/jdk21 if present, otherwise rely on JAVA_HOME already being set correctly.
if [ -z "${JAVA_HOME:-}" ] || "$JAVA_HOME/bin/java" -version 2>&1 | grep -qE '"(2[5-9]|[3-9][0-9])\.' ; then
if [ -d "$HOME/jdk21" ]; then
export JAVA_HOME="$HOME/jdk21"
export PATH="$JAVA_HOME/bin:$PATH"
info "Using local JDK 21: $JAVA_HOME"
else
abort "Java 25+ detected but Gradle only supports ≤ Java 24.\n\nInstall JDK 21 locally:\n wget -O /tmp/jdk21.tar.gz https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.7%2B6/OpenJDK21U-jdk_x64_linux_hotspot_21.0.7_6.tar.gz\n mkdir -p ~/jdk21 && tar -xzf /tmp/jdk21.tar.gz -C ~/jdk21 --strip-components=1"
fi
fi
info "Java: $("$JAVA_HOME/bin/java" -version 2>&1 | head -1)"
# ─── 3. Android SDK ────────────────────────────────────────────────────────
if [ -z "${ANDROID_HOME:-}" ]; then
for candidate in "$HOME/android-sdk" "$HOME/Android/Sdk" "/opt/android-sdk"; do
if [ -d "$candidate/platform-tools" ]; then
export ANDROID_HOME="$candidate"
break
fi
done
fi
if [ -z "${ANDROID_HOME:-}" ]; then
abort "Android SDK not found.\n\nRun: ./install-android-sdk.sh\nThen: export ANDROID_HOME=\"\$HOME/android-sdk\" && source ~/.bashrc"
fi
export PATH="$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin"
info "Android SDK: $ANDROID_HOME"
# ─── 4. expo prebuild ──────────────────────────────────────────────────────
info "Running expo prebuild (Android)…"
cd "$SCRIPT_DIR"
pnpm exec expo prebuild --platform android --clean --no-install
# ─── 4. Patch build.gradle — inject release signingConfig ──────────────────
info "Patching android/app/build.gradle for release signing…"
python3 - "$SCRIPT_DIR/android/app/build.gradle" << 'PYEOF'
import sys, re
path = sys.argv[1]
with open(path, 'r') as f:
content = f.read()
if 'MYAPP_UPLOAD_STORE_FILE' in content:
print('[patch] build.gradle already patched, skipping.')
sys.exit(0)
release_signing = """\n release {
if (project.hasProperty('MYAPP_UPLOAD_STORE_FILE')) {
storeFile file(MYAPP_UPLOAD_STORE_FILE)
storePassword MYAPP_UPLOAD_STORE_PASSWORD
keyAlias MYAPP_UPLOAD_KEY_ALIAS
keyPassword MYAPP_UPLOAD_KEY_PASSWORD
}
}"""
# Insert release block right after the closing brace of the debug signingConfig
content = re.sub(
r'(signingConfigs\s*\{[\s\S]*?debug\s*\{[\s\S]*?\})',
r'\1' + release_signing,
content,
count=1
)
# Switch release buildType from debug signing to release signing
# Only replace the first occurrence after "release {" (not the debug one)
def replace_release_signing(m):
return m.group(0).replace('signingConfig signingConfigs.debug',
'signingConfig signingConfigs.release', 1)
content = re.sub(
r'release\s*\{[^}]*signingConfig\s+signingConfigs\.debug',
replace_release_signing,
content,
count=1,
flags=re.DOTALL
)
with open(path, 'w') as f:
f.write(content)
print('[patch] build.gradle patched for release signing.')
PYEOF
# ─── 5. Inject signing props into gradle.properties ────────────────────────
info "Injecting signing credentials into gradle.properties…"
GRADLE_PROPS="$SCRIPT_DIR/android/gradle.properties"
# Remove any previous signing block left by this script
sed -i '/^# --- postiz release signing/,/^# --- end signing/d' "$GRADLE_PROPS" 2>/dev/null || true
cat >> "$GRADLE_PROPS" << EOF
# --- postiz release signing (injected by build-apk.sh — wiped after build)
MYAPP_UPLOAD_STORE_FILE=$KEYSTORE_PATH_EXPANDED
MYAPP_UPLOAD_STORE_PASSWORD=$KEYSTORE_STORE_PASSWORD
MYAPP_UPLOAD_KEY_ALIAS=$KEYSTORE_ALIAS
MYAPP_UPLOAD_KEY_PASSWORD=$KEYSTORE_KEY_PASSWORD
# --- end signing
EOF
# ─── 6. Gradle build ───────────────────────────────────────────────────────
cd "$SCRIPT_DIR/android"
if [ "$BUILD_TYPE" = "--aab" ]; then
info "Building AAB (release)…"
./gradlew bundleRelease
ARTIFACT="app/build/outputs/bundle/release/app-release.aab"
EXT="aab"
else
info "Building APK (release)…"
./gradlew assembleRelease
ARTIFACT="app/build/outputs/apk/release/app-release.apk"
EXT="apk"
fi
# ─── 7. Wipe credentials from gradle.properties ───────────────────────────
sed -i '/^# --- postiz release signing/,/^# --- end signing/d' "$GRADLE_PROPS"
info "Signing credentials wiped from gradle.properties."
# ─── 8. Copy to dist/ ─────────────────────────────────────────────────────
mkdir -p "$DIST_DIR"
TIMESTAMP="$(date +%Y%m%d-%H%M)"
OUTPUT="$DIST_DIR/postiz-mobile-$TIMESTAMP.$EXT"
cp "$ARTIFACT" "$OUTPUT"
echo ""
info "Build complete!"
echo -e " ${GREEN}$OUTPUT${NC}"
echo ""
@@ -18,6 +18,7 @@ interface PostCardProps {
post: PostizPost; post: PostizPost;
onDelete: (id: string) => Promise<void>; onDelete: (id: string) => Promise<void>;
onLongPress: (post: PostizPost) => void; onLongPress: (post: PostizPost) => void;
onReschedule?: (post: PostizPost) => void;
} }
function formatDate(dateStr: string): string { function formatDate(dateStr: string): string {
@@ -44,7 +45,7 @@ function getNetworkIcon(type?: string): React.ComponentProps<typeof Feather>["na
return "globe"; return "globe";
} }
export function PostCard({ post, onDelete, onLongPress }: PostCardProps) { export function PostCard({ post, onDelete, onLongPress, onReschedule }: PostCardProps) {
const colors = useColors(); const colors = useColors();
const swipeRef = useRef<Swipeable>(null); const swipeRef = useRef<Swipeable>(null);
@@ -88,6 +89,34 @@ export function PostCard({ post, onDelete, onLongPress }: PostCardProps) {
); );
}; };
const renderLeftActions =
post.state === "QUEUE" && onReschedule
? (
_progress: Animated.AnimatedInterpolation<number>,
dragX: Animated.AnimatedInterpolation<number>
) => {
const scale = dragX.interpolate({
inputRange: [0, 80],
outputRange: [0.8, 1],
extrapolate: "clamp",
});
return (
<TouchableOpacity
style={[styles.rescheduleAction, { backgroundColor: colors.warning }]}
onPress={() => {
swipeRef.current?.close();
onReschedule(post);
}}
activeOpacity={0.8}
>
<Animated.View style={{ transform: [{ scale }] }}>
<Feather name="clock" size={20} color="#fff" />
</Animated.View>
</TouchableOpacity>
);
}
: undefined;
const integrations = post.integrations ?? (post.integration ? [post.integration] : []); const integrations = post.integrations ?? (post.integration ? [post.integration] : []);
const truncatedContent = const truncatedContent =
post.content.length > 140 post.content.length > 140
@@ -98,7 +127,9 @@ export function PostCard({ post, onDelete, onLongPress }: PostCardProps) {
<Swipeable <Swipeable
ref={swipeRef} ref={swipeRef}
renderRightActions={renderRightActions} renderRightActions={renderRightActions}
renderLeftActions={renderLeftActions}
rightThreshold={40} rightThreshold={40}
leftThreshold={40}
friction={2} friction={2}
> >
<TouchableOpacity <TouchableOpacity
@@ -139,6 +170,19 @@ export function PostCard({ post, onDelete, onLongPress }: PostCardProps) {
{truncatedContent} {truncatedContent}
</Text> </Text>
<View style={styles.footer}> <View style={styles.footer}>
{integrations.length > 0 && (
<>
<Text style={[styles.accountName, { color: colors.mutedForeground }]} numberOfLines={1}>
{integrations
.slice(0, 2)
.map((i) => i.name || i.identifier || "")
.filter(Boolean)
.join(", ")}
{integrations.length > 2 ? ` +${integrations.length - 2}` : ""}
</Text>
<Text style={[styles.dot, { color: colors.mutedForeground }]}>·</Text>
</>
)}
<Feather name="clock" size={12} color={colors.mutedForeground} /> <Feather name="clock" size={12} color={colors.mutedForeground} />
<Text style={[styles.date, { color: colors.mutedForeground }]}> <Text style={[styles.date, { color: colors.mutedForeground }]}>
{formatDate(post.publishDate)} {formatDate(post.publishDate)}
@@ -191,9 +235,23 @@ const styles = StyleSheet.create({
fontSize: 12, fontSize: 12,
fontFamily: "Inter_400Regular", fontFamily: "Inter_400Regular",
}, },
accountName: {
fontSize: 12,
fontFamily: "Inter_400Regular",
flexShrink: 1,
},
dot: {
fontSize: 12,
marginHorizontal: 3,
},
deleteAction: { deleteAction: {
width: 72, width: 72,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}, },
rescheduleAction: {
width: 72,
alignItems: "center",
justifyContent: "center",
},
}); });
@@ -5,12 +5,13 @@ import React, {
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
useRef,
useState, useState,
} from "react"; } from "react";
const API_KEY_STORAGE = "postiz_api_key"; const API_KEY_STORAGE = "postiz_api_key";
const BASE_URL_STORAGE = "postiz_base_url"; const BASE_URL_STORAGE = "postiz_base_url";
const DEFAULT_BASE_URL = "https://postiz.gyozamancave.fr/api/public/v1"; export const DEFAULT_BASE_URL = "https://postiz.gyozamancave.fr/api/public/v1";
export interface PostizIntegration { export interface PostizIntegration {
id: string; id: string;
@@ -48,6 +49,8 @@ interface PostizContextValue {
baseUrl: string; baseUrl: string;
isConfigured: boolean; isConfigured: boolean;
isLoading: boolean; isLoading: boolean;
unauthorized: boolean;
clearUnauthorized: () => void;
client: AxiosInstance | null; client: AxiosInstance | null;
saveSettings: (apiKey: string, baseUrl: string) => Promise<void>; saveSettings: (apiKey: string, baseUrl: string) => Promise<void>;
clearSettings: () => Promise<void>; clearSettings: () => Promise<void>;
@@ -58,12 +61,18 @@ const PostizContext = createContext<PostizContextValue>({
baseUrl: DEFAULT_BASE_URL, baseUrl: DEFAULT_BASE_URL,
isConfigured: false, isConfigured: false,
isLoading: true, isLoading: true,
unauthorized: false,
clearUnauthorized: () => {},
client: null, client: null,
saveSettings: async () => {}, saveSettings: async () => {},
clearSettings: async () => {}, clearSettings: async () => {},
}); });
function createClient(apiKey: string, baseUrl: string): AxiosInstance { function createClient(
apiKey: string,
baseUrl: string,
onUnauthorized?: () => void
): AxiosInstance {
const normalizedUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"; const normalizedUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
const instance = axios.create({ const instance = axios.create({
baseURL: normalizedUrl, baseURL: normalizedUrl,
@@ -77,6 +86,15 @@ function createClient(apiKey: string, baseUrl: string): AxiosInstance {
console.log(">>> REQUEST:", config.method?.toUpperCase(), (config.baseURL || "") + (config.url || "")); console.log(">>> REQUEST:", config.method?.toUpperCase(), (config.baseURL || "") + (config.url || ""));
return config; return config;
}); });
instance.interceptors.response.use(
(res) => res,
(err) => {
if (axios.isAxiosError(err) && err.response?.status === 401) {
onUnauthorized?.();
}
return Promise.reject(err);
}
);
return instance; return instance;
} }
@@ -85,6 +103,19 @@ export function PostizProvider({ children }: { children: React.ReactNode }) {
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL); const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [client, setClient] = useState<AxiosInstance | null>(null); const [client, setClient] = useState<AxiosInstance | null>(null);
const [unauthorized, setUnauthorized] = useState(false);
const unauthorizedFiredRef = useRef(false);
const handleUnauthorized = useCallback(() => {
if (unauthorizedFiredRef.current) return;
unauthorizedFiredRef.current = true;
setUnauthorized(true);
}, []);
const clearUnauthorized = useCallback(() => {
unauthorizedFiredRef.current = false;
setUnauthorized(false);
}, []);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -95,14 +126,14 @@ export function PostizProvider({ children }: { children: React.ReactNode }) {
const url = (storedUrl || DEFAULT_BASE_URL).replace(/\/$/, ""); const url = (storedUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
setApiKey(storedKey); setApiKey(storedKey);
setBaseUrl(url); setBaseUrl(url);
setClient(() => createClient(storedKey, url)); setClient(() => createClient(storedKey, url, handleUnauthorized));
} }
} catch { } catch {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
})(); })();
}, []); }, [handleUnauthorized]);
const saveSettings = useCallback( const saveSettings = useCallback(
async (newApiKey: string, newBaseUrl: string) => { async (newApiKey: string, newBaseUrl: string) => {
@@ -110,9 +141,10 @@ export function PostizProvider({ children }: { children: React.ReactNode }) {
await SecureStore.setItemAsync(BASE_URL_STORAGE, newBaseUrl); await SecureStore.setItemAsync(BASE_URL_STORAGE, newBaseUrl);
setApiKey(newApiKey); setApiKey(newApiKey);
setBaseUrl(newBaseUrl); setBaseUrl(newBaseUrl);
setClient(() => createClient(newApiKey, newBaseUrl)); clearUnauthorized();
setClient(() => createClient(newApiKey, newBaseUrl, handleUnauthorized));
}, },
[] [handleUnauthorized, clearUnauthorized]
); );
const clearSettings = useCallback(async () => { const clearSettings = useCallback(async () => {
@@ -121,7 +153,8 @@ export function PostizProvider({ children }: { children: React.ReactNode }) {
setApiKey(""); setApiKey("");
setBaseUrl(DEFAULT_BASE_URL); setBaseUrl(DEFAULT_BASE_URL);
setClient(null); setClient(null);
}, []); clearUnauthorized();
}, [clearUnauthorized]);
return ( return (
<PostizContext.Provider <PostizContext.Provider
@@ -130,6 +163,8 @@ export function PostizProvider({ children }: { children: React.ReactNode }) {
baseUrl, baseUrl,
isConfigured: !!apiKey, isConfigured: !!apiKey,
isLoading, isLoading,
unauthorized,
clearUnauthorized,
client, client,
saveSettings, saveSettings,
clearSettings, clearSettings,
+2 -14
View File
@@ -4,20 +4,8 @@
}, },
"build": { "build": {
"preview": { "preview": {
"android": { "android": { "buildType": "apk" },
"buildType": "apk" "ios": { "simulator": true }
}
},
"production": {
"android": {
"buildType": "app-bundle"
},
"ios": {
"resourceClass": "m-medium"
}
} }
},
"submit": {
"production": {}
} }
} }
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# install-android-sdk.sh — Install Android SDK command-line tools only (no Android Studio).
# Installs to ~/android-sdk. Run once; takes ~1 GB of disk space.
set -euo pipefail
SDK_DIR="$HOME/android-sdk"
CMDLINE_TOOLS_URL="https://dl.google.com/android/repository/commandlinetools-linux-12266719_latest.zip"
CMDLINE_ZIP="/tmp/android-cmdline-tools.zip"
GREEN='\033[0;32m'; NC='\033[0m'
info() { echo -e "${GREEN}[sdk-install]${NC} $*"; }
if [ -d "$SDK_DIR/cmdline-tools/latest/bin" ]; then
info "Android SDK already installed at $SDK_DIR"
exit 0
fi
info "Downloading Android command-line tools…"
wget -q --show-progress -O "$CMDLINE_ZIP" "$CMDLINE_TOOLS_URL"
info "Extracting…"
mkdir -p "$SDK_DIR/cmdline-tools"
unzip -q "$CMDLINE_ZIP" -d "$SDK_DIR/cmdline-tools"
mv "$SDK_DIR/cmdline-tools/cmdline-tools" "$SDK_DIR/cmdline-tools/latest"
rm "$CMDLINE_ZIP"
export ANDROID_HOME="$SDK_DIR"
export PATH="$PATH:$SDK_DIR/cmdline-tools/latest/bin:$SDK_DIR/platform-tools"
info "Accepting licenses…"
yes | sdkmanager --licenses > /dev/null 2>&1 || true
info "Installing SDK components (platform-tools, build-tools 35, NDK 28)…"
sdkmanager \
"platform-tools" \
"platforms;android-35" \
"build-tools;35.0.0" \
"ndk;28.2.13676358"
info "Done. Add this to your ~/.bashrc or ~/.zshrc:"
echo ""
echo ' export ANDROID_HOME="$HOME/android-sdk"'
echo ' export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools"'
echo ""
info "Then reload your shell: source ~/.bashrc"
@@ -0,0 +1,20 @@
import axios from "axios";
export function extractError(err: unknown): string {
if (axios.isAxiosError(err)) {
const status = err.response?.status;
const data = err.response?.data;
if (data) {
const body =
typeof data === "string"
? data.slice(0, 200)
: (data?.message ?? data?.error ?? JSON.stringify(data)).toString().slice(0, 200);
return status ? `HTTP ${status}: ${body}` : body;
}
if (status) return `HTTP ${status}${err.message}`;
if (err.code === "ECONNABORTED") return "Request timed out. Check that the URL is reachable.";
if (err.message) return err.message;
}
if (err instanceof Error) return err.message;
return "Unknown error";
}
+14 -1
View File
@@ -1,3 +1,16 @@
const { getDefaultConfig } = require("expo/metro-config"); const { getDefaultConfig } = require("expo/metro-config");
const path = require("path");
module.exports = getDefaultConfig(__dirname); const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, "../..");
const config = getDefaultConfig(projectRoot);
// pnpm monorepo: expose workspace root node_modules to Metro
config.watchFolders = [workspaceRoot];
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, "node_modules"),
path.resolve(workspaceRoot, "node_modules"),
];
module.exports = config;
+11 -5
View File
@@ -4,13 +4,16 @@
"private": true, "private": true,
"main": "expo-router/entry", "main": "expo-router/entry",
"scripts": { "scripts": {
"dev": "EXPO_PACKAGER_PROXY_URL=https://$REPLIT_EXPO_DEV_DOMAIN EXPO_PUBLIC_DOMAIN=$REPLIT_DEV_DOMAIN EXPO_PUBLIC_REPL_ID=$REPL_ID REACT_NATIVE_PACKAGER_HOSTNAME=$REPLIT_DEV_DOMAIN pnpm exec expo start --localhost --port $PORT", "dev": "pnpm exec expo start",
"build": "node scripts/build.js", "build": "node scripts/build.js",
"serve": "node server/serve.js", "serve": "node server/serve.js",
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit",
"android": "expo run:android",
"ios": "expo run:ios"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",
"babel-preset-expo": "~54.0.10",
"@expo-google-fonts/inter": "^0.4.0", "@expo-google-fonts/inter": "^0.4.0",
"@expo/cli": "54.0.23", "@expo/cli": "54.0.23",
"@expo/ngrok": "^4.1.0", "@expo/ngrok": "^4.1.0",
@@ -23,13 +26,14 @@
"@ungap/structured-clone": "^1.3.0", "@ungap/structured-clone": "^1.3.0",
"@workspace/api-client-react": "workspace:*", "@workspace/api-client-react": "workspace:*",
"babel-plugin-react-compiler": "^19.0.0-beta-e993439-20250117", "babel-plugin-react-compiler": "^19.0.0-beta-e993439-20250117",
"expo": "~54.0.27", "expo": "~54.0.34",
"expo-blur": "~15.0.8", "expo-blur": "~15.0.8",
"expo-constants": "~18.0.11", "expo-constants": "~18.0.11",
"expo-font": "~14.0.10", "expo-font": "~14.0.10",
"expo-glass-effect": "~0.1.4", "expo-glass-effect": "~0.1.4",
"expo-haptics": "~15.0.8", "expo-haptics": "~15.0.8",
"expo-image": "~3.0.11", "expo-image": "~3.0.11",
"expo-image-manipulator": "~13.0.6",
"expo-image-picker": "~17.0.9", "expo-image-picker": "~17.0.9",
"expo-linear-gradient": "~15.0.8", "expo-linear-gradient": "~15.0.8",
"expo-linking": "~8.0.10", "expo-linking": "~8.0.10",
@@ -61,7 +65,9 @@
"expo-clipboard": "~8.0.8", "expo-clipboard": "~8.0.8",
"expo-notifications": "~0.32.17", "expo-notifications": "~0.32.17",
"expo-secure-store": "~15.0.8", "expo-secure-store": "~15.0.8",
"expo-task-manager": "~14.0.9", "react-native-calendars": "^1.1314.0",
"react-native-calendars": "^1.1314.0" "expo": "~54.0.34",
"react": "19.1.0",
"react-native": "0.81.5"
} }
} }
+2 -10
View File
@@ -55,20 +55,12 @@ function stripProtocol(domain) {
} }
function getDeploymentDomain() { function getDeploymentDomain() {
if (process.env.REPLIT_INTERNAL_APP_DOMAIN) {
return stripProtocol(process.env.REPLIT_INTERNAL_APP_DOMAIN);
}
if (process.env.REPLIT_DEV_DOMAIN) {
return stripProtocol(process.env.REPLIT_DEV_DOMAIN);
}
if (process.env.EXPO_PUBLIC_DOMAIN) { if (process.env.EXPO_PUBLIC_DOMAIN) {
return stripProtocol(process.env.EXPO_PUBLIC_DOMAIN); return stripProtocol(process.env.EXPO_PUBLIC_DOMAIN);
} }
console.error( console.error(
"ERROR: No deployment domain found. Set REPLIT_INTERNAL_APP_DOMAIN, REPLIT_DEV_DOMAIN, or EXPO_PUBLIC_DOMAIN", "ERROR: No deployment domain found. Set EXPO_PUBLIC_DOMAIN.",
); );
process.exit(1); process.exit(1);
} }
@@ -124,7 +116,7 @@ async function checkMetroHealth() {
} }
function getExpoPublicReplId() { function getExpoPublicReplId() {
return process.env.REPL_ID || process.env.EXPO_PUBLIC_REPL_ID; return process.env.EXPO_PUBLIC_REPL_ID;
} }
async function startMetro(expoPublicDomain, expoPublicReplId) { async function startMetro(expoPublicDomain, expoPublicReplId) {
+36 -21
View File
@@ -407,9 +407,6 @@ importers:
expo-secure-store: expo-secure-store:
specifier: ~15.0.8 specifier: ~15.0.8
version: 15.0.8(expo@54.0.34) version: 15.0.8(expo@54.0.34)
expo-task-manager:
specifier: ~14.0.9
version: 14.0.9(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))
react-native-calendars: react-native-calendars:
specifier: ^1.1314.0 specifier: ^1.1314.0
version: 1.1314.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) version: 1.1314.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -453,8 +450,11 @@ importers:
babel-plugin-react-compiler: babel-plugin-react-compiler:
specifier: ^19.0.0-beta-e993439-20250117 specifier: ^19.0.0-beta-e993439-20250117
version: 19.0.0-beta-ebf51a3-20250411 version: 19.0.0-beta-ebf51a3-20250411
babel-preset-expo:
specifier: ~54.0.10
version: 54.0.10(@babel/core@7.29.0)(@babel/runtime@7.28.6)(expo@54.0.34)(react-refresh@0.18.0)
expo: expo:
specifier: ~54.0.27 specifier: ~54.0.34
version: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) version: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3)
expo-blur: expo-blur:
specifier: ~15.0.8 specifier: ~15.0.8
@@ -3556,12 +3556,6 @@ packages:
react-native-web: react-native-web:
optional: true optional: true
expo-task-manager@14.0.9:
resolution: {integrity: sha512-GKWtXrkedr4XChHfTm5IyTcSfMtCPxzx89y4CMVqKfyfROATibrE/8UI5j7UC/pUOfFoYlQvulQEvECMreYuUA==}
peerDependencies:
expo: '*'
react-native: '*'
expo-web-browser@15.0.11: expo-web-browser@15.0.11:
resolution: {integrity: sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==} resolution: {integrity: sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==}
peerDependencies: peerDependencies:
@@ -5581,9 +5575,6 @@ packages:
resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==} resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==}
engines: {node: '>=20'} engines: {node: '>=20'}
unimodules-app-loader@6.0.8:
resolution: {integrity: sha512-fqS8QwT/MC/HAmw1NKCHdzsPA6WaLm0dNmoC5Pz6lL+cDGYeYCNdHMO9fy08aL2ZD7cVkNM0pSR/AoNRe+rslA==}
universalify@2.0.1: universalify@2.0.1:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
@@ -8783,6 +8774,38 @@ snapshots:
- '@babel/core' - '@babel/core'
- supports-color - supports-color
babel-preset-expo@54.0.10(@babel/core@7.29.0)(@babel/runtime@7.28.6)(expo@54.0.34)(react-refresh@0.18.0):
dependencies:
'@babel/helper-module-imports': 7.28.6
'@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0)
'@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.0)
'@babel/plugin-syntax-export-default-from': 7.28.6(@babel/core@7.29.0)
'@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.0)
'@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.0)
'@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.0)
'@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0)
'@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.0)
'@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0)
'@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0)
'@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0)
'@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.0)
'@babel/preset-react': 7.28.5(@babel/core@7.29.0)
'@babel/preset-typescript': 7.28.5(@babel/core@7.29.0)
'@react-native/babel-preset': 0.81.5(@babel/core@7.29.0)
babel-plugin-react-compiler: 1.0.0
babel-plugin-react-native-web: 0.21.2
babel-plugin-syntax-hermes-parser: 0.29.1
babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.0)
debug: 4.4.3
react-refresh: 0.18.0
resolve-from: 5.0.0
optionalDependencies:
'@babel/runtime': 7.28.6
expo: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3)
transitivePeerDependencies:
- '@babel/core'
- supports-color
babel-preset-jest@29.6.3(@babel/core@7.29.0): babel-preset-jest@29.6.3(@babel/core@7.29.0):
dependencies: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
@@ -9585,12 +9608,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
expo-task-manager@14.0.9(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)):
dependencies:
expo: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3)
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
unimodules-app-loader: 6.0.8
expo-web-browser@15.0.11(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)): expo-web-browser@15.0.11(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)):
dependencies: dependencies:
expo: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) expo: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3)
@@ -11934,8 +11951,6 @@ snapshots:
unicorn-magic@0.4.0: {} unicorn-magic@0.4.0: {}
unimodules-app-loader@6.0.8: {}
universalify@2.0.1: {} universalify@2.0.1: {}
unpipe@1.0.0: {} unpipe@1.0.0: {}
-64
View File
@@ -1,64 +0,0 @@
# Workspace
## Overview
pnpm workspace monorepo using TypeScript. Each package manages its own dependencies.
## Stack
- **Monorepo tool**: pnpm workspaces
- **Node.js version**: 24
- **Package manager**: pnpm
- **TypeScript version**: 5.9
- **API framework**: Express 5
- **Database**: PostgreSQL + Drizzle ORM
- **Validation**: Zod (`zod/v4`), `drizzle-zod`
- **API codegen**: Orval (from OpenAPI spec)
- **Build**: esbuild (CJS bundle)
## Artifacts
### PostizMobile (`artifacts/postiz-mobile`)
Expo (React Native) mobile client for a self-hosted Postiz instance.
- **Preview path**: `/`
- **Theme**: Dark-only (`userInterfaceStyle: dark`)
- **Auth**: API key stored in `expo-secure-store`, passed as `Authorization` header
#### Screens / Tabs
1. **Calendar** (`app/(tabs)/index.tsx`) — Monthly calendar with post dots, tap day to see posts
2. **Posts** (`app/(tabs)/posts.tsx`) — Filterable list of posts with status badges, swipe to delete
3. **Compose** (`app/(tabs)/compose.tsx`) — Text editor, channel picker, date/time picker, image upload
4. **Settings** (`app/(tabs)/settings.tsx`) — API key + base URL, validation, SecureStore persistence
#### Key Files
- `context/PostizContext.tsx` — Axios client wired with API key/base URL; loaded from SecureStore on boot
- `components/PostCard.tsx` — Swipeable post card with delete action
- `components/StatusBadge.tsx` — QUEUE / PUBLISHED / ERROR / DRAFT badges
- `components/ChannelChip.tsx` — Integration channel selector chip
#### External API
- Base URL: `https://postiz.gyozamancave.fr/public/v1` (configurable)
- `GET /integrations` — List channels
- `GET /posts?startDate&endDate` — List posts
- `POST /posts` — Create/schedule post
- `DELETE /posts/:id` — Delete post
- `POST /upload` — Upload media
#### Packages Added
- `axios` — HTTP client
- `expo-secure-store` — Secure API key storage
- `react-native-calendars` — Calendar UI
- `@react-native-community/datetimepicker` — Date/time picker for compose
### API Server (`artifacts/api-server`)
Express 5 backend. Currently serves `/api/healthz`. Extend for server-side features.
## Key Commands
- `pnpm run typecheck` — full typecheck across all packages
- `pnpm run build` — typecheck + build all packages
- `pnpm --filter @workspace/api-spec run codegen` — regenerate API hooks and Zod schemas from OpenAPI spec
- `pnpm --filter @workspace/db run push` — push DB schema changes (dev only)
See the `pnpm-workspace` skill for workspace structure, TypeScript setup, and package details.
-25
View File
@@ -1,25 +0,0 @@
#!/bin/bash
set -euo pipefail
GITEA_HOST="homegit.gyozamancave.fr"
GITEA_USER="billisdead"
GITEA_REPO="Postiz-android"
GITEA_REMOTE_NAME="gitea"
GITEA_REMOTE_URL="https://${GITEA_HOST}/${GITEA_USER}/${GITEA_REPO}.git"
if [ -z "${GITEA_SSH_KEY:-}" ]; then
echo "Error: GITEA_SSH_KEY environment variable is not set. Add the Gitea API token as a Replit secret named GITEA_SSH_KEY." >&2
exit 1
fi
if git remote get-url "$GITEA_REMOTE_NAME" &>/dev/null; then
git remote set-url "$GITEA_REMOTE_NAME" "$GITEA_REMOTE_URL"
else
git remote add "$GITEA_REMOTE_NAME" "$GITEA_REMOTE_URL"
echo "Added remote '$GITEA_REMOTE_NAME'."
fi
echo "Pushing main branch to Gitea..."
git -c "http.extraHeader=Authorization: token ${GITEA_SSH_KEY}" \
push --force "$GITEA_REMOTE_NAME" main
echo "Push to Gitea complete."