26 Commits

Author SHA1 Message Date
billisdead 8b7a2eb644 feat: multi-workspace support + channels grouped by workspace and network
Release APK / build (push) Has been cancelled
- PostizContext: new PostizWorkspace type, multi-workspace storage
  (postiz_workspaces_v2), auto-migration from legacy single config,
  addWorkspace / updateWorkspace / removeWorkspace, clients map
- Settings: full rewrite with workspace card list (add / edit / delete)
- Compose: channels displayed in two levels — workspace section then
  network type (X/Twitter, Instagram, LinkedIn...) within each workspace;
  submit routes posts and image uploads per workspace
- MediaLibraryModal: workspace tabs when multiple workspaces configured,
  returned items carry their workspaceId

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 14:50:20 +02:00
billisdead d4c16ccf97 chore: translate release notes to English
Release APK / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:21:44 +02:00
billisdead 0696f5663e feat: multi-images, media library, + fix HTML in notifications
Release APK / build (push) Has been cancelled
Multi-images (compose):
- Replace single imageUri with mediaItems: MediaItem[] (local | uploaded)
- allowsMultipleSelection: true, selectionLimit up to 4 total
- Each picked image is resized to max 1920px before upload
- Thumbnail row with individual × remove buttons
- uploaded badge (cloud icon) on library/prefill images
- buildMediaPayload() uploads local items, passes uploaded items as-is

Media Library:
- New MediaLibraryModal component — full-screen modal
- Fetches GET /media from Postiz instance
- 3-column grid with multi-select (capped at remaining slots)
- Selected items added to compose media pool

Notifications:
- Strip HTML from notification body text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 17:09:08 +02:00
billisdead 4a531df8bd fix: strip HTML-encoded tags (decode entities before stripping)
Release APK / build (push) Has been cancelled
The previous stripHtml decoded &lt;/&gt; after the regex pass, so content
stored as &lt;p&gt;text&lt;/p&gt; was never stripped. Now entities are
decoded first, then all tags are removed.

Also strip HTML when prefilling compose from an existing post (Edit/Repost)
so the text field shows clean content, not raw markup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 17:01:15 +02:00
billisdead 365f44dbe4 feat: official Postiz icon + strip HTML from post content display
Release APK / build (push) Has been cancelled
- Replace icon.png with official Postiz logo (1024x1024, generated from
  upstream postiz.svg at gitroomhq/postiz-app)
- Add lib/stripHtml.ts: converts <br>/<p> to newlines, strips all tags,
  decodes HTML entities
- PostCard: use stripHtml on content before truncation and display
- posts.tsx: use stripHtml for context menu preview and clipboard copy
  (API payloads keep original HTML for retry/reschedule)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 16:08:43 +02:00
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
billisdead 3191691fff feat: add long-press contextual actions on post cards
Long press any post card to open a context menu with state-aware actions:
- Copy text (all states)
- ERROR: Retry now, Edit & retry, View error message
- QUEUE: Edit, Reschedule (native DateTimePicker → PUT /posts/:id)
- PUBLISHED: Repost
- DRAFT: Edit & schedule

Compose screen now accepts prefillContent/prefillIntegrationIds router
params to pre-fill content and channel selection when editing or reposting.
Adds expo-clipboard for clipboard support.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:52:07 +02:00
billisdead 803f147fbb fix: remove Replit-specific expo-router origin from app.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 08:06:00 +02:00
27 changed files with 2363 additions and 1635 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 }}
Signed APK for Android — direct install (sideload).
### Installation
1. Enable "Unknown sources" on the device
2. Transfer the APK to the device and open it to install
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*
+11 -10
View File
@@ -23,23 +23,19 @@
"android": { "android": {
"package": "fr.gyozamancave.postizmobile", "package": "fr.gyozamancave.postizmobile",
"permissions": [ "permissions": [
"READ_EXTERNAL_STORAGE", "android.permission.READ_EXTERNAL_STORAGE",
"WRITE_EXTERNAL_STORAGE", "android.permission.WRITE_EXTERNAL_STORAGE",
"READ_MEDIA_IMAGES", "android.permission.READ_MEDIA_IMAGES",
"RECEIVE_BOOT_COMPLETED", "android.permission.RECEIVE_BOOT_COMPLETED",
"VIBRATE" "android.permission.VIBRATE",
"android.permission.RECORD_AUDIO"
] ]
}, },
"web": { "web": {
"favicon": "./assets/images/icon.png" "favicon": "./assets/images/icon.png"
}, },
"plugins": [ "plugins": [
[
"expo-router", "expo-router",
{
"origin": "https://replit.com/"
}
],
"expo-font", "expo-font",
"expo-web-browser", "expo-web-browser",
"expo-image-picker", "expo-image-picker",
@@ -56,6 +52,11 @@
"experiments": { "experiments": {
"typedRoutes": true, "typedRoutes": true,
"reactCompiler": true "reactCompiler": true
},
"extra": {
"eas": {
"projectId": "aeaaa2bd-3a27-4771-8e39-f2e14fe0e030"
}
} }
} }
} }
+474 -233
View File
@@ -1,11 +1,14 @@
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 React, { useState } from "react"; import { useLocalSearchParams } from "expo-router";
import React, { useEffect, useMemo, useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
@@ -21,14 +24,74 @@ import {
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 { ChannelChip } from "@/components/ChannelChip"; import { ChannelChip } from "@/components/ChannelChip";
import { PostizIntegration, PostizUploadResult, usePostiz } from "@/context/PostizContext"; import { LibraryMediaItem, MediaLibraryModal } from "@/components/MediaLibraryModal";
import {
PostizIntegration,
PostizUploadResult,
PostizWorkspace,
usePostiz,
} from "@/context/PostizContext";
import { useColors } from "@/hooks/useColors"; import { useColors } from "@/hooks/useColors";
const DRAFT_STORAGE_KEY = "postiz_local_draft";
const MAX_IMAGES = 4;
const NETWORK_CHAR_LIMITS: Record<string, number> = {
twitter: 280, x: 280,
instagram: 2200,
linkedin: 3000,
facebook: 63206,
youtube: 5000,
tiktok: 2200,
};
// Integration enriched with its workspace info
type IntegrationWithWorkspace = PostizIntegration & {
workspaceId: string;
workspaceName: string;
workspace: PostizWorkspace;
};
type MediaItem =
| { type: "local"; uri: string }
| { type: "uploaded"; id: string; path: string; workspaceId: string };
// Maps a type string to a display label, used for grouping within a workspace
function networkLabel(intg: PostizIntegration): string {
const t = (intg.type ?? intg.internalType ?? "").toLowerCase();
if (t.includes("twitter") || t.includes("x-") || t === "x") return "X / Twitter";
if (t.includes("instagram")) return "Instagram";
if (t.includes("linkedin")) return "LinkedIn";
if (t.includes("facebook")) return "Facebook";
if (t.includes("tiktok")) return "TikTok";
if (t.includes("youtube")) return "YouTube";
if (t.includes("pinterest")) return "Pinterest";
if (t.includes("mastodon")) return "Mastodon";
if (t.includes("bluesky") || t.includes("bsky")) return "Bluesky";
if (t.includes("threads")) return "Threads";
if (t.includes("reddit")) return "Reddit";
return "Other";
}
function resolveMediaUrl(path: string, baseUrl: string): string {
if (path.startsWith("http://") || path.startsWith("https://")) return path;
const origin = baseUrl.replace(/\/api\/.*$/, "");
return `${origin}/${path.replace(/^\//, "")}`;
}
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 { workspaces, clients, isConfigured } = usePostiz();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId } =
useLocalSearchParams<{
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);
@@ -37,22 +100,118 @@ 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 [mediaItems, setMediaItems] = useState<MediaItem[]>([]);
const [showMediaLibrary, setShowMediaLibrary] = useState(false);
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);
const { data: integrations, isLoading: loadingIntegrations } = useEffect(() => {
useQuery<PostizIntegration[]>({ if (prefillContent) setContent(String(prefillContent));
queryKey: ["integrations", !!client], if (prefillIntegrationIds) {
setSelectedChannels(String(prefillIntegrationIds).split(",").filter(Boolean));
}
if (prefillImagePath && prefillImageId) {
// Prefilled image has unknown workspace; associate with first workspace
const wsId = workspaces[0]?.id ?? "";
setMediaItems([{ type: "uploaded", id: String(prefillImageId), path: String(prefillImagePath), workspaceId: wsId }]);
}
}, [prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId, workspaces]);
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]);
// Fetch integrations from ALL workspaces in parallel
const { data: allIntegrations, isLoading: loadingIntegrations } =
useQuery<IntegrationWithWorkspace[]>({
queryKey: ["integrations-all", workspaces.map((w) => w.id).join(",")],
queryFn: async () => { queryFn: async () => {
const results = await Promise.all(
workspaces.map(async (ws) => {
const client = clients[ws.id];
if (!client) return []; if (!client) return [];
const res = await client.get("integrations"); const res = await client.get("integrations");
return Array.isArray(res.data) ? res.data : res.data?.integrations ?? []; const list: PostizIntegration[] = Array.isArray(res.data)
? res.data
: (res.data?.integrations ?? []);
return list.map((i): IntegrationWithWorkspace => ({
...i,
workspaceId: ws.id,
workspaceName: ws.name,
workspace: ws,
}));
})
);
return results.flat();
}, },
enabled: !!client, enabled: workspaces.length > 0 && Object.keys(clients).length > 0,
staleTime: 60000, staleTime: 60000,
}); });
// Group: workspace → network label → integrations
const grouped = useMemo(() => {
if (!allIntegrations) return [];
const byWorkspace = new Map<string, IntegrationWithWorkspace[]>();
for (const intg of allIntegrations) {
if (!byWorkspace.has(intg.workspaceId)) byWorkspace.set(intg.workspaceId, []);
byWorkspace.get(intg.workspaceId)!.push(intg);
}
return workspaces
.filter((ws) => byWorkspace.has(ws.id))
.map((ws) => {
const intgs = byWorkspace.get(ws.id)!;
const byNetwork = new Map<string, IntegrationWithWorkspace[]>();
for (const intg of intgs) {
const key = networkLabel(intg);
if (!byNetwork.has(key)) byNetwork.set(key, []);
byNetwork.get(key)!.push(intg);
}
return {
workspace: ws,
networks: Array.from(byNetwork.entries()).map(([label, channels]) => ({ label, channels })),
};
});
}, [allIntegrations, workspaces]);
const effectiveCharLimit = useMemo(() => {
if (selectedChannels.length === 0 || !allIntegrations) return 3000;
const selected = allIntegrations.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);
}, [selectedChannels, allIntegrations]);
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 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]
@@ -60,52 +219,84 @@ export default function ComposeScreen() {
}; };
const pickImage = async () => { const pickImage = async () => {
if (mediaItems.length >= MAX_IMAGES) {
Alert.alert("Max images", `You can add up to ${MAX_IMAGES} images per post.`);
return;
}
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== "granted") { if (status !== "granted") {
Alert.alert("Permission required", "Allow access to your photo library."); Alert.alert("Permission required", "Allow access to your photo library.");
return; return;
} }
const remaining = MAX_IMAGES - mediaItems.length;
const result = await ImagePicker.launchImageLibraryAsync({ const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"], mediaTypes: ["images"],
allowsMultipleSelection: true,
selectionLimit: remaining,
allowsEditing: false, allowsEditing: false,
quality: 0.85, quality: 1,
}); });
if (!result.canceled && result.assets[0]) { if (!result.canceled && result.assets.length > 0) {
setImageUri(result.assets[0].uri); const MAX_DIM = 1920;
const processed: string[] = [];
for (const asset of result.assets) {
const w = asset.width ?? 0;
const h = asset.height ?? 0;
if (w > MAX_DIM || h > MAX_DIM) {
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 }
);
processed.push(resized.uri);
} else {
processed.push(asset.uri);
}
}
setMediaItems((prev) =>
[...prev, ...processed.map((uri): MediaItem => ({ type: "local", uri }))].slice(0, MAX_IMAGES)
);
} }
}; };
const removeImage = () => setImageUri(null); const removeMediaItem = (index: number) => {
setMediaItems((prev) => prev.filter((_, i) => i !== index));
};
const uploadImage = async (): Promise<PostizUploadResult> => { // Upload local images to a specific workspace, returns { id, path }[]
setUploading(true); const uploadLocalToWorkspace = async (
try { localUris: string[],
ws: PostizWorkspace
): Promise<Array<{ id: string; path: string }>> => {
const result: Array<{ id: string; path: string }> = [];
for (const uri of localUris) {
const formData = new FormData(); const formData = new FormData();
if (Platform.OS === "web") { if (Platform.OS === "web") {
const response = await expoFetch(imageUri!); const response = await expoFetch(uri);
const blob = await response.blob(); const blob = await response.blob();
formData.append("file", blob, "upload.jpg"); formData.append("file", blob, "upload.jpg");
} else { } else {
formData.append("file", { formData.append("file", {
uri: imageUri!, uri,
name: "upload.jpg", name: "upload.jpg",
type: "image/jpeg", type: "image/jpeg",
} as unknown as Blob); } as unknown as Blob);
} }
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
const uploadRes = await globalThis.fetch(`${baseUrl}/upload`, { const uploadRes = await globalThis.fetch(`${ws.baseUrl}/upload`, {
method: "POST", method: "POST",
headers: { Authorization: apiKey }, headers: { Authorization: ws.apiKey },
body: formData, body: formData,
}); });
if (!uploadRes.ok) { if (!uploadRes.ok) {
const raw = await uploadRes.text().catch(() => uploadRes.statusText); const raw = await uploadRes.text().catch(() => uploadRes.statusText);
throw new Error(`Upload HTTP ${uploadRes.status}: ${raw.slice(0, 200)}`); throw new Error(`[${ws.name}] Upload HTTP ${uploadRes.status}: ${raw.slice(0, 200)}`);
} }
return await uploadRes.json() as PostizUploadResult; const uploaded = (await uploadRes.json()) as PostizUploadResult;
} finally { result.push({ id: uploaded.id, path: uploaded.path });
setUploading(false);
} }
return result;
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -120,31 +311,55 @@ export default function ComposeScreen() {
} }
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
setSubmitting(true); setSubmitting(true);
try { try {
let media: Array<{ id: string; path: string }> = []; // Group selected channels by workspace
if (imageUri) { const byWorkspace = new Map<string, { ws: PostizWorkspace; channelIds: string[] }>();
const uploaded = await uploadImage(); for (const channelId of selectedChannels) {
media = [{ id: uploaded.id, path: uploaded.path }]; const intg = allIntegrations?.find((i) => i.id === channelId);
if (!intg) continue;
if (!byWorkspace.has(intg.workspaceId)) {
byWorkspace.set(intg.workspaceId, { ws: intg.workspace, channelIds: [] });
} }
byWorkspace.get(intg.workspaceId)!.channelIds.push(channelId);
}
const localUris = mediaItems.filter((m): m is MediaItem & { type: "local" } => m.type === "local").map((m) => m.uri);
const hasLocalImages = localUris.length > 0;
if (hasLocalImages) setUploading(true);
await Promise.all(
Array.from(byWorkspace.values()).map(async ({ ws, channelIds }) => {
// Already-uploaded media belonging to this workspace
const uploadedForWs = mediaItems
.filter((m): m is MediaItem & { type: "uploaded" } => m.type === "uploaded" && m.workspaceId === ws.id)
.map(({ id, path }) => ({ id, path }));
// Upload local images to this workspace
const localUploaded = hasLocalImages
? await uploadLocalToWorkspace(localUris, ws)
: [];
const media = [...uploadedForWs, ...localUploaded];
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: channelIds.map((integrationId) => ({
integration: { id: integrationId }, integration: { id: integrationId },
value: [{ content: content.trim(), id: "", image: media }], value: [{ content: content.trim(), image: media }],
})), })),
}; };
const body = JSON.stringify(payload); const body = JSON.stringify(payload);
console.log("[compose] POST", `${ws.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(`${ws.baseUrl}/posts`, {
method: "POST", method: "POST",
headers: { headers: { Authorization: ws.apiKey, "Content-Type": "application/json" },
Authorization: apiKey,
"Content-Type": "application/json",
},
body, body,
}); });
@@ -152,14 +367,17 @@ 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][${ws.name}] error body:`, raw);
} catch { detail = raw.slice(0, 500);
detail = res.statusText; } catch { detail = res.statusText; }
} throw new Error(`[${ws.name}] HTTP ${res.status}: ${detail}`);
throw new Error(`HTTP ${res.status}: ${detail}`);
} }
})
);
if (hasLocalImages) setUploading(false);
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.",
@@ -168,6 +386,7 @@ export default function ComposeScreen() {
queryClient.invalidateQueries({ queryKey: ["posts"] }); queryClient.invalidateQueries({ queryKey: ["posts"] });
queryClient.invalidateQueries({ queryKey: ["posts-list"] }); queryClient.invalidateQueries({ queryKey: ["posts-list"] });
} catch (e: unknown) { } catch (e: unknown) {
setUploading(false);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
const msg = e instanceof Error ? e.message : "Could not submit post."; const msg = e instanceof Error ? e.message : "Could not submit post.";
Alert.alert("Failed", msg); Alert.alert("Failed", msg);
@@ -180,16 +399,13 @@ export default function ComposeScreen() {
setContent(""); setContent("");
setSelectedChannels([]); setSelectedChannels([]);
setPostNow(false); setPostNow(false);
setImageUri(null); setMediaItems([]);
setDraftBanner(false);
setScheduleDate(new Date(Date.now() + 60 * 60 * 1000)); setScheduleDate(new Date(Date.now() + 60 * 60 * 1000));
}; };
const formatDateLabel = (d: Date) => const formatDateLabel = (d: Date) =>
d.toLocaleDateString("en-US", { d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
month: "short",
day: "numeric",
year: "numeric",
});
const formatTimeLabel = (d: Date) => const formatTimeLabel = (d: Date) =>
d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" }); d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
@@ -198,17 +414,14 @@ export default function ComposeScreen() {
return ( return (
<View style={[styles.centered, { backgroundColor: colors.background }]}> <View style={[styles.centered, { backgroundColor: colors.background }]}>
<Feather name="lock" size={32} color={colors.mutedForeground} /> <Feather name="lock" size={32} color={colors.mutedForeground} />
<Text style={[styles.sectionTitle, { color: colors.foreground }]}> <Text style={[styles.sectionTitle, { color: colors.foreground }]}>Not Configured</Text>
Not Configured <Text style={[styles.hint, { color: colors.mutedForeground }]}>Add a workspace in Settings</Text>
</Text>
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
Add your API key in Settings
</Text>
</View> </View>
); );
} }
return ( return (
<>
<KeyboardAwareScrollView <KeyboardAwareScrollView
style={{ flex: 1, backgroundColor: colors.background }} style={{ flex: 1, backgroundColor: colors.background }}
contentContainerStyle={[ contentContainerStyle={[
@@ -222,12 +435,20 @@ export default function ComposeScreen() {
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
<View {draftBanner && (
style={[ <View style={[styles.draftBanner, { backgroundColor: colors.card, borderColor: colors.border }]}>
styles.textArea, <Feather name="file-text" size={14} color={colors.primary} />
{ backgroundColor: colors.card, borderColor: colors.border }, <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={() => setDraftBanner(false)} activeOpacity={0.7}>
<Feather name="x" size={14} color={colors.mutedForeground} />
</TouchableOpacity>
</View>
)}
<View style={[styles.textArea, { backgroundColor: colors.card, borderColor: colors.border }]}>
<TextInput <TextInput
style={[styles.textInput, { color: colors.foreground }]} style={[styles.textInput, { color: colors.foreground }]}
placeholder="What do you want to post?" placeholder="What do you want to post?"
@@ -235,61 +456,133 @@ 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 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> </Text>
</View> </View>
</View>
{imageUri && ( {mediaItems.length > 0 && (
<View style={styles.imagePreviewWrap}> <ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.imageRow}
>
{mediaItems.map((item, idx) => {
const uri =
item.type === "local"
? item.uri
: resolveMediaUrl(item.path, workspaces.find((w) => w.id === item.workspaceId)?.baseUrl ?? "");
return (
<View key={idx} style={styles.imageThumbWrap}>
<Image <Image
source={{ uri: imageUri }} source={{ uri }}
style={[styles.imagePreview, { borderColor: colors.border }]} style={[styles.imageThumb, { borderColor: colors.border }]}
contentFit="cover" contentFit="cover"
/> />
<TouchableOpacity <TouchableOpacity
onPress={removeImage} onPress={() => removeMediaItem(idx)}
style={[styles.removeImg, { backgroundColor: colors.destructive }]} style={[styles.removeImg, { backgroundColor: colors.destructive }]}
> >
<Feather name="x" size={12} color="#fff" /> <Feather name="x" size={12} color="#fff" />
</TouchableOpacity> </TouchableOpacity>
{item.type === "uploaded" && (
<View style={[styles.uploadedBadge, { backgroundColor: colors.success }]}>
<Feather name="cloud" size={8} color="#fff" />
</View> </View>
)} )}
</View>
);
})}
</ScrollView>
)}
{mediaItems.length < MAX_IMAGES && (
<View style={styles.mediaBtnsRow}>
<TouchableOpacity <TouchableOpacity
onPress={pickImage} onPress={pickImage}
activeOpacity={0.7} activeOpacity={0.7}
style={[ style={[styles.mediaBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
styles.mediaBtn,
{ backgroundColor: colors.card, borderColor: colors.border },
]}
> >
<Feather name="image" size={16} color={colors.mutedForeground} /> <Feather name="image" size={16} color={colors.mutedForeground} />
<Text style={[styles.mediaBtnText, { color: colors.mutedForeground }]}> <Text style={[styles.mediaBtnText, { color: colors.mutedForeground }]}>
{imageUri ? "Change image" : "Add image"} {mediaItems.length === 0 ? "Add image" : "Add more"}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity
onPress={() => setShowMediaLibrary(true)}
activeOpacity={0.7}
style={[styles.mediaBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
>
<Feather name="folder" size={16} color={colors.mutedForeground} />
<Text style={[styles.mediaBtnText, { color: colors.mutedForeground }]}>Library</Text>
</TouchableOpacity>
</View>
)}
<Text style={[styles.sectionLabel, { color: colors.mutedForeground }]}> {/* Channels grouped by workspace then network type */}
CHANNELS <Text style={[styles.sectionLabel, { color: colors.mutedForeground }]}>CHANNELS</Text>
</Text>
{loadingIntegrations ? ( {loadingIntegrations ? (
<ActivityIndicator color={colors.primary} style={{ marginVertical: 8 }} /> <ActivityIndicator color={colors.primary} style={{ marginVertical: 8 }} />
) : (integrations ?? []).length === 0 ? ( ) : grouped.length === 0 ? (
<Text style={[styles.hint, { color: colors.mutedForeground }]}> <Text style={[styles.hint, { color: colors.mutedForeground }]}>
No channels found. Add integrations in your Postiz instance. No channels found. Add integrations in your Postiz instance.
</Text> </Text>
) : ( ) : (
<ScrollView <View style={styles.channelGroups}>
horizontal {grouped.map(({ workspace, networks }, wsIdx) => (
showsHorizontalScrollIndicator={false} <View
contentContainerStyle={styles.channelList} key={workspace.id}
style={[
styles.workspaceSection,
{
backgroundColor: colors.card,
borderColor: colors.border,
},
wsIdx > 0 && { marginTop: 8 },
]}
> >
{(integrations ?? []).map((intg) => ( {/* Workspace header */}
<View style={[styles.workspaceHeader, { borderBottomColor: colors.border }]}>
<Feather name="briefcase" size={12} color={colors.primary} />
<Text style={[styles.workspaceName, { color: colors.primary }]}>
{workspace.name}
</Text>
</View>
{/* Network groups */}
<View style={styles.networkGroups}>
{networks.map(({ label, channels }) => (
<View key={label} style={styles.networkGroup}>
{networks.length > 1 && (
<Text style={[styles.networkLabel, { color: colors.mutedForeground }]}>
{label}
</Text>
)}
<View style={styles.chipRow}>
{channels.map((intg) => (
<ChannelChip <ChannelChip
key={intg.id} key={intg.id}
integration={intg} integration={intg}
@@ -297,20 +590,19 @@ export default function ComposeScreen() {
onToggle={() => toggleChannel(intg.id)} onToggle={() => toggleChannel(intg.id)}
/> />
))} ))}
</ScrollView> </View>
</View>
))}
</View>
</View>
))}
</View>
)} )}
<View <View style={[styles.scheduleRow, { backgroundColor: colors.card, borderColor: colors.border }]}>
style={[
styles.scheduleRow,
{ backgroundColor: colors.card, borderColor: colors.border },
]}
>
<View style={styles.scheduleRowLeft}> <View style={styles.scheduleRowLeft}>
<Feather name="zap" size={16} color={colors.primary} /> <Feather name="zap" size={16} color={colors.primary} />
<Text style={[styles.scheduleLabel, { color: colors.foreground }]}> <Text style={[styles.scheduleLabel, { color: colors.foreground }]}>Post now</Text>
Post now
</Text>
</View> </View>
<Switch <Switch
value={postNow} value={postNow}
@@ -323,14 +615,8 @@ export default function ComposeScreen() {
{!postNow && ( {!postNow && (
<View style={styles.dateTimeRow}> <View style={styles.dateTimeRow}>
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => { setShowTimePicker(false); setShowDatePicker((v) => !v); }}
setShowTimePicker(false); style={[styles.dateBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
setShowDatePicker((v) => !v);
}}
style={[
styles.dateBtn,
{ backgroundColor: colors.card, borderColor: colors.border },
]}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Feather name="calendar" size={14} color={colors.primary} /> <Feather name="calendar" size={14} color={colors.primary} />
@@ -339,14 +625,8 @@ export default function ComposeScreen() {
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => { setShowDatePicker(false); setShowTimePicker((v) => !v); }}
setShowDatePicker(false); style={[styles.dateBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
setShowTimePicker((v) => !v);
}}
style={[
styles.dateBtn,
{ backgroundColor: colors.card, borderColor: colors.border },
]}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Feather name="clock" size={14} color={colors.primary} /> <Feather name="clock" size={14} color={colors.primary} />
@@ -394,27 +674,30 @@ 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}
disabled={submitting || uploading} disabled={submitting || uploading}
style={[ style={[
styles.submitBtn, styles.submitBtn,
{ { backgroundColor: submitting || uploading ? colors.muted : colors.primary },
backgroundColor:
submitting || uploading ? colors.muted : colors.primary,
},
]} ]}
> >
{submitting || uploading ? ( {submitting || uploading ? (
<ActivityIndicator color={colors.primaryForeground} size="small" /> <ActivityIndicator color={colors.primaryForeground} size="small" />
) : ( ) : (
<> <>
<Feather <Feather name={postNow ? "send" : "clock"} size={16} color={colors.primaryForeground} />
name={postNow ? "send" : "clock"}
size={16}
color={colors.primaryForeground}
/>
<Text style={[styles.submitText, { color: colors.primaryForeground }]}> <Text style={[styles.submitText, { color: colors.primaryForeground }]}>
{postNow ? "Publish Now" : "Schedule Post"} {postNow ? "Publish Now" : "Schedule Post"}
</Text> </Text>
@@ -422,139 +705,97 @@ export default function ComposeScreen() {
)} )}
</TouchableOpacity> </TouchableOpacity>
</KeyboardAwareScrollView> </KeyboardAwareScrollView>
<MediaLibraryModal
visible={showMediaLibrary}
workspaces={workspaces}
maxSelect={MAX_IMAGES - mediaItems.length}
onClose={() => setShowMediaLibrary(false)}
onSelect={(items: LibraryMediaItem[]) => {
setMediaItems((prev) =>
[
...prev,
...items.map((i): MediaItem => ({
type: "uploaded",
id: i.id,
path: i.path,
workspaceId: i.workspaceId,
})),
].slice(0, MAX_IMAGES)
);
setShowMediaLibrary(false);
}}
/>
</>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: { paddingHorizontal: 16, gap: 14 },
paddingHorizontal: 16, centered: { flex: 1, alignItems: "center", justifyContent: "center", gap: 10 },
gap: 14, textArea: { borderRadius: 14, borderWidth: 1, padding: 14, minHeight: 140 },
textInput: { fontSize: 15, fontFamily: "Inter_400Regular", lineHeight: 22, minHeight: 100 },
charCountRow: { flexDirection: "row", justifyContent: "flex-end", alignItems: "center", gap: 6, marginTop: 4 },
charCountLabel: { fontSize: 10, fontFamily: "Inter_400Regular" },
charCount: { fontSize: 11, fontFamily: "Inter_400Regular" },
draftBanner: {
flexDirection: "row", alignItems: "center", gap: 8,
paddingHorizontal: 14, paddingVertical: 10, borderRadius: 12, borderWidth: 1,
}, },
centered: { draftBannerText: { flex: 1, fontSize: 13, fontFamily: "Inter_400Regular" },
flex: 1, draftBannerAction: { fontSize: 13, fontFamily: "Inter_600SemiBold" },
alignItems: "center", draftBtn: {
justifyContent: "center", flexDirection: "row", alignItems: "center", justifyContent: "center",
gap: 10, gap: 6, paddingVertical: 10, borderRadius: 12, borderWidth: 1,
},
textArea: {
borderRadius: 14,
borderWidth: 1,
padding: 14,
minHeight: 140,
},
textInput: {
fontSize: 15,
fontFamily: "Inter_400Regular",
lineHeight: 22,
minHeight: 100,
},
charCount: {
fontSize: 11,
fontFamily: "Inter_400Regular",
alignSelf: "flex-end",
marginTop: 4,
},
imagePreviewWrap: {
position: "relative",
alignSelf: "flex-start",
},
imagePreview: {
width: 120,
height: 120,
borderRadius: 10,
borderWidth: 1,
}, },
draftBtnText: { fontSize: 13, fontFamily: "Inter_500Medium" },
imageRow: { gap: 10, paddingRight: 4 },
imageThumbWrap: { position: "relative" },
imageThumb: { width: 100, height: 100, borderRadius: 10, borderWidth: 1 },
removeImg: { removeImg: {
position: "absolute", position: "absolute", top: 4, right: 4,
top: 4, width: 20, height: 20, borderRadius: 10, alignItems: "center", justifyContent: "center",
right: 4,
width: 20,
height: 20,
borderRadius: 10,
alignItems: "center",
justifyContent: "center",
}, },
uploadedBadge: {
position: "absolute", bottom: 4, left: 4,
width: 16, height: 16, borderRadius: 8, alignItems: "center", justifyContent: "center",
},
mediaBtnsRow: { flexDirection: "row", gap: 8 },
mediaBtn: { mediaBtn: {
flexDirection: "row", flexDirection: "row", alignItems: "center", gap: 8,
alignItems: "center", paddingHorizontal: 14, paddingVertical: 10, borderRadius: 10, borderWidth: 1,
gap: 8,
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 10,
borderWidth: 1,
alignSelf: "flex-start",
}, },
mediaBtnText: { mediaBtnText: { fontSize: 13, fontFamily: "Inter_500Medium" },
fontSize: 13, sectionLabel: { fontSize: 11, fontFamily: "Inter_600SemiBold", letterSpacing: 0.8, marginBottom: -6 },
fontFamily: "Inter_500Medium", sectionTitle: { fontSize: 18, fontFamily: "Inter_600SemiBold" },
}, hint: { fontSize: 13, fontFamily: "Inter_400Regular", textAlign: "center" },
sectionLabel: { channelGroups: { gap: 0 },
fontSize: 11, workspaceSection: { borderRadius: 14, borderWidth: 1, overflow: "hidden" },
fontFamily: "Inter_600SemiBold", workspaceHeader: {
letterSpacing: 0.8, flexDirection: "row", alignItems: "center", gap: 6,
marginBottom: -6, paddingHorizontal: 12, paddingVertical: 8,
}, borderBottomWidth: StyleSheet.hairlineWidth,
sectionTitle: {
fontSize: 18,
fontFamily: "Inter_600SemiBold",
},
hint: {
fontSize: 13,
fontFamily: "Inter_400Regular",
textAlign: "center",
},
channelList: {
flexDirection: "row",
gap: 8,
flexWrap: "wrap",
}, },
workspaceName: { fontSize: 11, fontFamily: "Inter_600SemiBold", letterSpacing: 0.5 },
networkGroups: { padding: 10, gap: 10 },
networkGroup: { gap: 4 },
networkLabel: { fontSize: 10, fontFamily: "Inter_500Medium", letterSpacing: 0.4, marginLeft: 2 },
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
scheduleRow: { scheduleRow: {
flexDirection: "row", flexDirection: "row", alignItems: "center", justifyContent: "space-between",
alignItems: "center", paddingHorizontal: 16, paddingVertical: 12, borderRadius: 12, borderWidth: 1,
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
},
scheduleRowLeft: {
flexDirection: "row",
alignItems: "center",
gap: 10,
},
scheduleLabel: {
fontSize: 15,
fontFamily: "Inter_500Medium",
},
dateTimeRow: {
flexDirection: "row",
gap: 10,
}, },
scheduleRowLeft: { flexDirection: "row", alignItems: "center", gap: 10 },
scheduleLabel: { fontSize: 15, fontFamily: "Inter_500Medium" },
dateTimeRow: { flexDirection: "row", gap: 10 },
dateBtn: { dateBtn: {
flex: 1, flex: 1, flexDirection: "row", alignItems: "center", gap: 8,
flexDirection: "row", paddingHorizontal: 12, paddingVertical: 10, borderRadius: 10, borderWidth: 1,
alignItems: "center",
gap: 8,
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 10,
borderWidth: 1,
},
dateBtnText: {
fontSize: 13,
fontFamily: "Inter_500Medium",
}, },
dateBtnText: { fontSize: 13, fontFamily: "Inter_500Medium" },
submitBtn: { submitBtn: {
flexDirection: "row", flexDirection: "row", alignItems: "center", justifyContent: "center",
alignItems: "center", gap: 8, paddingVertical: 14, borderRadius: 14, marginTop: 4,
justifyContent: "center",
gap: 8,
paddingVertical: 14,
borderRadius: 14,
marginTop: 4,
},
submitText: {
fontSize: 15,
fontFamily: "Inter_600SemiBold",
}, },
submitText: { fontSize: 15, fontFamily: "Inter_600SemiBold" },
}); });
+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 }]}>
+243 -35
View File
@@ -1,7 +1,11 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
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 React, { useMemo, useState } from "react"; import * as Clipboard from "expo-clipboard";
import * as Haptics from "expo-haptics";
import { useRouter } from "expo-router";
import React, { useEffect, useMemo, useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
@@ -17,24 +21,10 @@ 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";
import { stripHtml } from "@/lib/stripHtml";
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";
@@ -51,8 +41,28 @@ export default function PostsScreen() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { client, isConfigured } = usePostiz(); const { client, isConfigured } = usePostiz();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
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
const [reschedulePost, setReschedulePost] = useState<PostizPost | null>(null);
const [rescheduleDate, setRescheduleDate] = useState(new Date());
const [rescheduleStep, setRescheduleStep] = useState<"date" | "time" | null>(null);
const { startDate, endDate } = useMemo(() => { const { startDate, endDate } = useMemo(() => {
const s = new Date(); const s = new Date();
@@ -76,10 +86,27 @@ export default function PostsScreen() {
staleTime: 0, staleTime: 0,
}); });
const filteredPosts = const filteredPosts = useMemo(() => {
const list =
filter === "all" filter === "all"
? posts ?? [] ? posts ?? []
: (posts ?? []).filter((p) => p.state === filter); : (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);
@@ -99,6 +126,118 @@ export default function PostsScreen() {
} }
}; };
const handleRetry = async (post: PostizPost) => {
if (!client) return;
const integrations = post.integrations ?? (post.integration ? [post.integration] : []);
try {
const payload = {
type: "now",
date: new Date().toISOString(),
shortLink: false,
tags: [] as string[],
posts: integrations.map((intg) => ({
integration: { id: intg.id },
value: [{ content: post.content, id: "", image: post.image ?? [] }],
})),
};
await client.post("posts", payload);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
queryClient.invalidateQueries({ queryKey: ["posts-list"] });
Alert.alert("Retried", "Post submitted again.");
} catch (e: unknown) {
const msg = extractError(e);
Alert.alert("Retry failed", msg);
}
};
const handlePrefillCompose = (post: PostizPost) => {
const integrations = post.integrations ?? (post.integration ? [post.integration] : []);
router.push({
pathname: "/(tabs)/compose",
params: {
prefillContent: stripHtml(post.content),
prefillIntegrationIds: integrations.map((i) => i.id).join(","),
},
});
};
const startReschedule = (post: PostizPost) => {
setReschedulePost(post);
setRescheduleDate(new Date(post.publishDate));
setRescheduleStep("date");
};
const submitReschedule = async (post: PostizPost, date: Date) => {
if (!client) return;
const integrations = post.integrations ?? (post.integration ? [post.integration] : []);
try {
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"] });
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" })}`);
} catch (e: unknown) {
const msg = extractError(e);
Alert.alert("Reschedule failed", msg);
}
};
const showContextMenu = (post: PostizPost) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
const plain = stripHtml(post.content);
const preview = plain.slice(0, 60) + (plain.length > 60 ? "…" : "");
const buttons: Array<{ text: string; style?: "cancel" | "destructive" | "default"; onPress?: () => void }> = [];
buttons.push({
text: "Copy text",
onPress: async () => {
await Clipboard.setStringAsync(stripHtml(post.content));
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
setCopyToast(true);
setTimeout(() => setCopyToast(false), 2000);
},
});
if (post.state === "ERROR") {
if (post.errorMessage) {
buttons.push({
text: "View error",
onPress: () => Alert.alert("Error details", post.errorMessage),
});
}
buttons.push({ text: "Retry now", onPress: () => handleRetry(post) });
buttons.push({ text: "Edit & retry", onPress: () => handlePrefillCompose(post) });
} else if (post.state === "QUEUE") {
buttons.push({ text: "Edit", onPress: () => handlePrefillCompose(post) });
buttons.push({ text: "Reschedule", onPress: () => startReschedule(post) });
} else if (post.state === "PUBLISHED") {
buttons.push({ text: "Repost", onPress: () => handlePrefillCompose(post) });
} else if (post.state === "DRAFT") {
buttons.push({ text: "Edit & schedule", onPress: () => handlePrefillCompose(post) });
}
buttons.push({ text: "Cancel", style: "cancel" });
Alert.alert(
post.state === "ERROR" ? "Failed post" :
post.state === "QUEUE" ? "Scheduled post" :
post.state === "PUBLISHED" ? "Published post" : "Draft",
preview,
buttons
);
};
if (!isConfigured) { if (!isConfigured) {
return ( return (
<View <View
@@ -128,43 +267,61 @@ export default function PostsScreen() {
}, },
]} ]}
> >
<View style={[styles.filterRow, { borderBottomColor: colors.border }]}>
<FlatList <FlatList
horizontal horizontal
data={FILTERS} data={FILTERS}
keyExtractor={(item) => item.key} keyExtractor={(item) => item.key}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.filterList} contentContainerStyle={styles.filterList}
renderItem={({ item }) => ( renderItem={({ item }) => {
const count = posts ? filterCounts[item.key] : undefined;
const active = filter === item.key;
return (
<TouchableOpacity <TouchableOpacity
onPress={() => setFilter(item.key)} onPress={() => setFilter(item.key)}
activeOpacity={0.7} activeOpacity={0.7}
style={[ style={[
styles.filterChip, styles.filterChip,
{ {
backgroundColor: backgroundColor: active ? colors.primary : colors.secondary,
filter === item.key ? colors.primary : colors.secondary, borderColor: active ? colors.primary : colors.border,
borderColor:
filter === item.key ? colors.primary : colors.border,
}, },
]} ]}
> >
<Text <Text
style={[ style={[
styles.filterText, styles.filterText,
{ { color: active ? colors.primaryForeground : colors.mutedForeground },
color:
filter === item.key
? colors.primaryForeground
: colors.mutedForeground,
},
]} ]}
> >
{item.label} {item.label}
{count !== undefined && count > 0 ? ` ${count}` : ""}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
)} );
style={[styles.filterBar, { borderBottomColor: colors.border }]} }}
style={styles.filterBar}
/> />
<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}>
@@ -193,7 +350,12 @@ export default function PostsScreen() {
data={filteredPosts} data={filteredPosts}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
renderItem={({ item }) => ( renderItem={({ item }) => (
<PostCard post={item} onDelete={handleDelete} /> <PostCard
post={item}
onDelete={handleDelete}
onLongPress={showContextMenu}
onReschedule={startReschedule}
/>
)} )}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
@@ -220,6 +382,37 @@ export default function PostsScreen() {
scrollEnabled={filteredPosts.length > 0} scrollEnabled={filteredPosts.length > 0}
/> />
)} )}
{rescheduleStep !== null && reschedulePost !== null && (
<DateTimePicker
value={rescheduleDate}
mode={rescheduleStep}
display="default"
minimumDate={rescheduleStep === "date" ? new Date() : undefined}
textColor={colors.foreground}
accentColor={colors.primary}
onChange={(_: unknown, date?: Date) => {
if (!date) {
setRescheduleStep(null);
setReschedulePost(null);
return;
}
if (rescheduleStep === "date") {
const merged = new Date(rescheduleDate);
merged.setFullYear(date.getFullYear(), date.getMonth(), date.getDate());
setRescheduleDate(merged);
setRescheduleStep("time");
} else {
const merged = new Date(rescheduleDate);
merged.setHours(date.getHours(), date.getMinutes());
const post = reschedulePost;
setRescheduleStep(null);
setReschedulePost(null);
submitReschedule(post, merged);
}
}}
/>
)}
</View> </View>
); );
} }
@@ -233,13 +426,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" },
}); });
+212 -241
View File
@@ -1,7 +1,7 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import axios from "axios"; import axios from "axios";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
import React, { useEffect, useState } from "react"; import React, { useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
@@ -15,69 +15,73 @@ 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 { PostizWorkspace, DEFAULT_BASE_URL, usePostiz } 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"; type FormState = {
id?: string;
name: string;
url: string;
key: string;
};
function extractAxiosError(err: unknown): string { const EMPTY_FORM: FormState = { name: "", url: DEFAULT_BASE_URL, key: "" };
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();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { apiKey, baseUrl, isConfigured, saveSettings, clearSettings } = usePostiz(); const { workspaces, isConfigured, addWorkspace, updateWorkspace, removeWorkspace } = usePostiz();
const [inputKey, setInputKey] = useState(apiKey); const [form, setForm] = useState<FormState | null>(null);
const [inputUrl, setInputUrl] = useState(baseUrl || DEFAULT_BASE_URL);
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
const [validating, setValidating] = useState(false); const [validating, setValidating] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [validationStatus, setValidationStatus] = useState<"idle" | "ok" | "error">("idle"); const [validationStatus, setValidationStatus] = useState<"idle" | "ok" | "error">("idle");
const [errorDetail, setErrorDetail] = useState<string>(""); const [errorDetail, setErrorDetail] = useState("");
useEffect(() => { const openAdd = () => {
setInputKey(apiKey); setForm(EMPTY_FORM);
setInputUrl(baseUrl || DEFAULT_BASE_URL); setShowKey(false);
}, [apiKey, baseUrl]); resetValidation();
};
const openEdit = (ws: PostizWorkspace) => {
setForm({ id: ws.id, name: ws.name, url: ws.baseUrl, key: ws.apiKey });
setShowKey(false);
resetValidation();
};
const closeForm = () => {
setForm(null);
resetValidation();
};
const resetValidation = () => {
setValidationStatus("idle");
setErrorDetail("");
};
const patchForm = (patch: Partial<FormState>) => {
setForm((prev) => (prev ? { ...prev, ...patch } : prev));
resetValidation();
};
const handleValidate = async () => { const handleValidate = async () => {
if (!inputKey.trim() || !inputUrl.trim()) { if (!form?.key.trim() || !form?.url.trim()) {
Alert.alert("Missing fields", "Please enter both API key and base URL."); Alert.alert("Missing fields", "Please enter both API key and base URL.");
return; return;
} }
setValidating(true); setValidating(true);
setValidationStatus("idle"); resetValidation();
setErrorDetail(""); const cleanUrl = form.url.trim().replace(/\/$/, "");
const cleanUrl = inputUrl.trim().replace(/\/$/, ""); const variants = [form.key.trim(), `Bearer ${form.key.trim()}`];
let lastError = "";
const authVariants = [ for (const auth of variants) {
inputKey.trim(),
`Bearer ${inputKey.trim()}`,
];
let lastError: string = "";
for (const authHeader of authVariants) {
try { try {
await axios.get(`${cleanUrl}/integrations`, { await axios.get(`${cleanUrl}/integrations`, {
headers: { Authorization: authHeader }, headers: { Authorization: auth },
timeout: 10000, timeout: 10000,
maxRedirects: 0, maxRedirects: 0,
}); });
@@ -87,21 +91,17 @@ export default function SettingsScreen() {
return; return;
} catch (err: unknown) { } catch (err: unknown) {
if (axios.isAxiosError(err)) { if (axios.isAxiosError(err)) {
const status = err.response?.status; const s = err.response?.status;
if (status === 307 || status === 301 || status === 302 || status === 308) { if (s === 307 || s === 301 || s === 302 || s === 308) {
const location = err.response?.headers?.location ?? "unknown"; const loc = err.response?.headers?.location ?? "unknown";
lastError = `HTTP ${status} redirect → ${location}. The API rejected the request and redirected to login. Check the Authorization header format or the base URL.`; lastError = `HTTP ${s} redirect → ${loc}. Check the Authorization format or base URL.`;
continue; continue;
} }
if (status === 401 || status === 403) { if (s === 401 || s === 403) { lastError = `HTTP ${s}: Invalid or expired API key.`; continue; }
lastError = `HTTP ${status}: Invalid or expired API key.`; }
continue; lastError = extractError(err);
} }
} }
lastError = extractAxiosError(err);
}
}
setErrorDetail(lastError); setErrorDetail(lastError);
setValidationStatus("error"); setValidationStatus("error");
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
@@ -109,37 +109,38 @@ export default function SettingsScreen() {
}; };
const handleSave = async () => { const handleSave = async () => {
if (!inputKey.trim() || !inputUrl.trim()) { if (!form) return;
Alert.alert("Missing fields", "Please enter both API key and base URL."); if (!form.name.trim()) { Alert.alert("Missing name", "Please enter a name for this workspace."); return; }
return; if (!form.key.trim() || !form.url.trim()) { Alert.alert("Missing fields", "Please enter both API key and base URL."); return; }
}
setSaving(true); setSaving(true);
try { try {
await saveSettings(inputKey.trim(), inputUrl.trim().replace(/\/$/, "")); const ws = { name: form.name.trim(), apiKey: form.key.trim(), baseUrl: form.url.trim().replace(/\/$/, "") };
if (form.id) {
await updateWorkspace({ ...ws, id: form.id });
} else {
await addWorkspace(ws);
}
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
Alert.alert("Saved", "Settings saved successfully."); closeForm();
} catch (err: unknown) { } catch (err: unknown) {
Alert.alert("Error", `Failed to save settings.\n${extractAxiosError(err)}`); Alert.alert("Error", `Failed to save.\n${extractError(err)}`);
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
const handleClear = () => { const handleDelete = (ws: PostizWorkspace) => {
Alert.alert( Alert.alert(
"Disconnect", "Remove workspace",
"Remove your API key and disconnect from Postiz?", `Remove "${ws.name}"? Channels from this workspace will no longer be available.`,
[ [
{ text: "Cancel", style: "cancel" }, { text: "Cancel", style: "cancel" },
{ {
text: "Disconnect", text: "Remove",
style: "destructive", style: "destructive",
onPress: async () => { onPress: async () => {
await clearSettings(); if (form?.id === ws.id) closeForm();
setInputKey(""); await removeWorkspace(ws.id);
setInputUrl(DEFAULT_BASE_URL);
setValidationStatus("idle");
setErrorDetail("");
}, },
}, },
] ]
@@ -160,34 +161,96 @@ export default function SettingsScreen() {
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{!isConfigured && ( {/* Status banner */}
{!isConfigured ? (
<View style={[styles.banner, { backgroundColor: colors.primary + "18", borderColor: colors.primary + "40" }]}> <View style={[styles.banner, { backgroundColor: colors.primary + "18", borderColor: colors.primary + "40" }]}>
<Feather name="info" size={16} color={colors.primary} /> <Feather name="info" size={16} color={colors.primary} />
<Text style={[styles.bannerText, { color: colors.primary }]}> <Text style={[styles.bannerText, { color: colors.primary }]}>
Connect to your Postiz instance to get started Add a workspace to get started
</Text> </Text>
</View> </View>
)} ) : (
{isConfigured && (
<View style={[styles.connectedBadge, { backgroundColor: colors.success + "18", borderColor: colors.success + "40" }]}> <View style={[styles.connectedBadge, { backgroundColor: colors.success + "18", borderColor: colors.success + "40" }]}>
<Feather name="check-circle" size={14} color={colors.success} /> <Feather name="check-circle" size={14} color={colors.success} />
<Text style={[styles.connectedText, { color: colors.success }]}> <Text style={[styles.connectedText, { color: colors.success }]}>
Connected to Postiz {workspaces.length} workspace{workspaces.length > 1 ? "s" : ""} configured
</Text> </Text>
</View> </View>
)} )}
<View style={styles.section}> {/* Workspace cards */}
{workspaces.map((ws) => (
<View key={ws.id} style={[styles.wsCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
<View style={styles.wsCardHeader}>
<View style={styles.wsCardLeft}>
<View style={[styles.wsIcon, { backgroundColor: colors.primary + "18" }]}>
<Feather name="briefcase" size={14} color={colors.primary} />
</View>
<View>
<Text style={[styles.wsName, { color: colors.foreground }]}>{ws.name}</Text>
<Text style={[styles.wsUrl, { color: colors.mutedForeground }]} numberOfLines={1}>
{ws.baseUrl.replace(/^https?:\/\//, "").replace(/\/api.*$/, "")}
</Text>
</View>
</View>
<View style={styles.wsCardActions}>
<TouchableOpacity onPress={() => openEdit(ws)} activeOpacity={0.7} style={styles.iconBtn}>
<Feather name="edit-2" size={15} color={colors.mutedForeground} />
</TouchableOpacity>
<TouchableOpacity onPress={() => handleDelete(ws)} activeOpacity={0.7} style={styles.iconBtn}>
<Feather name="trash-2" size={15} color={colors.destructive} />
</TouchableOpacity>
</View>
</View>
</View>
))}
{/* Add workspace button */}
{!form && (
<TouchableOpacity
onPress={openAdd}
activeOpacity={0.8}
style={[styles.addBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
>
<Feather name="plus" size={16} color={colors.primary} />
<Text style={[styles.addBtnText, { color: colors.primary }]}>Add workspace</Text>
</TouchableOpacity>
)}
{/* Add / Edit form */}
{form && (
<View style={[styles.formCard, { backgroundColor: colors.card, borderColor: colors.border }]}>
<Text style={[styles.formTitle, { color: colors.foreground }]}>
{form.id ? "Edit workspace" : "Add workspace"}
</Text>
{/* Name */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>NAME</Text>
<View style={[styles.inputWrap, { backgroundColor: colors.background, borderColor: colors.border }]}>
<Feather name="briefcase" size={15} color={colors.mutedForeground} />
<TextInput
style={[styles.input, { color: colors.foreground }]}
placeholder="My Client"
placeholderTextColor={colors.mutedForeground}
value={form.name}
onChangeText={(t) => patchForm({ name: t })}
autoCorrect={false}
/>
</View>
</View>
{/* Base URL */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>BASE URL</Text> <Text style={[styles.label, { color: colors.mutedForeground }]}>BASE URL</Text>
<View style={[styles.inputWrap, { backgroundColor: colors.card, borderColor: colors.border }]}> <View style={[styles.inputWrap, { backgroundColor: colors.background, borderColor: colors.border }]}>
<Feather name="globe" size={16} color={colors.mutedForeground} style={styles.inputIcon} /> <Feather name="globe" size={15} color={colors.mutedForeground} />
<TextInput <TextInput
style={[styles.input, { color: colors.foreground }]} style={[styles.input, { color: colors.foreground }]}
placeholder="https://postiz.example.com/api/public/v1" placeholder="https://postiz.example.com/api/public/v1"
placeholderTextColor={colors.mutedForeground} placeholderTextColor={colors.mutedForeground}
value={inputUrl} value={form.url}
onChangeText={(t) => { setInputUrl(t); setValidationStatus("idle"); setErrorDetail(""); }} onChangeText={(t) => patchForm({ url: t })}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
keyboardType="url" keyboardType="url"
@@ -195,31 +258,30 @@ export default function SettingsScreen() {
</View> </View>
</View> </View>
<View style={styles.section}> {/* API Key */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.mutedForeground }]}>API KEY</Text> <Text style={[styles.label, { color: colors.mutedForeground }]}>API KEY</Text>
<View style={[styles.inputWrap, { backgroundColor: colors.card, borderColor: colors.border }]}> <View style={[styles.inputWrap, { backgroundColor: colors.background, borderColor: colors.border }]}>
<Feather name="key" size={16} color={colors.mutedForeground} style={styles.inputIcon} /> <Feather name="key" size={15} color={colors.mutedForeground} />
<TextInput <TextInput
style={[styles.input, { color: colors.foreground }]} style={[styles.input, { color: colors.foreground }]}
placeholder="Enter your API key" placeholder="Enter your API key"
placeholderTextColor={colors.mutedForeground} placeholderTextColor={colors.mutedForeground}
value={inputKey} value={form.key}
onChangeText={(t) => { setInputKey(t); setValidationStatus("idle"); setErrorDetail(""); }} onChangeText={(t) => patchForm({ key: t })}
secureTextEntry={!showKey} secureTextEntry={!showKey}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
/> />
<TouchableOpacity onPress={() => setShowKey((v) => !v)} activeOpacity={0.7}> <TouchableOpacity onPress={() => setShowKey((v) => !v)} activeOpacity={0.7}>
<Feather name={showKey ? "eye-off" : "eye"} size={16} color={colors.mutedForeground} /> <Feather name={showKey ? "eye-off" : "eye"} size={15} color={colors.mutedForeground} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{validationStatus === "ok" && ( {validationStatus === "ok" && (
<View style={styles.validationRow}> <View style={styles.validRow}>
<Feather name="check-circle" size={13} color={colors.success} /> <Feather name="check-circle" size={13} color={colors.success} />
<Text style={[styles.validationText, { color: colors.success }]}> <Text style={[styles.validText, { color: colors.success }]}>Connection successful</Text>
Connection successful
</Text>
</View> </View>
)} )}
@@ -227,39 +289,42 @@ export default function SettingsScreen() {
<View style={[styles.errorBox, { backgroundColor: colors.error + "12", borderColor: colors.error + "30" }]}> <View style={[styles.errorBox, { backgroundColor: colors.error + "12", borderColor: colors.error + "30" }]}>
<View style={styles.errorHeader}> <View style={styles.errorHeader}>
<Feather name="x-circle" size={13} color={colors.error} /> <Feather name="x-circle" size={13} color={colors.error} />
<Text style={[styles.errorTitle, { color: colors.error }]}> <Text style={[styles.errorTitle, { color: colors.error }]}>Could not connect</Text>
Could not connect
</Text>
</View> </View>
{!!errorDetail && ( {!!errorDetail && (
<ScrollView style={styles.errorScroll} nestedScrollEnabled> <ScrollView style={styles.errorScroll} nestedScrollEnabled>
<Text style={[styles.errorDetail, { color: colors.error }]} selectable> <Text style={[styles.errorDetail, { color: colors.error }]} selectable>{errorDetail}</Text>
{errorDetail}
</Text>
</ScrollView> </ScrollView>
)} )}
</View> </View>
)} )}
</View> </View>
{/* Form actions */}
<TouchableOpacity <TouchableOpacity
onPress={handleValidate} onPress={handleValidate}
activeOpacity={0.8} activeOpacity={0.8}
disabled={validating} disabled={validating}
style={[styles.validateBtn, { backgroundColor: colors.card, borderColor: colors.border }]} style={[styles.validateBtn, { backgroundColor: colors.background, borderColor: colors.border }]}
> >
{validating ? ( {validating ? (
<ActivityIndicator color={colors.primary} size="small" /> <ActivityIndicator color={colors.primary} size="small" />
) : ( ) : (
<> <>
<Feather name="wifi" size={15} color={colors.primary} /> <Feather name="wifi" size={15} color={colors.primary} />
<Text style={[styles.validateText, { color: colors.primary }]}> <Text style={[styles.validateText, { color: colors.primary }]}>Test Connection</Text>
Test Connection
</Text>
</> </>
)} )}
</TouchableOpacity> </TouchableOpacity>
<View style={styles.formBtnsRow}>
<TouchableOpacity
onPress={closeForm}
activeOpacity={0.8}
style={[styles.cancelBtn, { borderColor: colors.border }]}
>
<Text style={[styles.cancelText, { color: colors.mutedForeground }]}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
onPress={handleSave} onPress={handleSave}
activeOpacity={0.85} activeOpacity={0.85}
@@ -272,169 +337,75 @@ export default function SettingsScreen() {
<> <>
<Feather name="save" size={15} color={colors.primaryForeground} /> <Feather name="save" size={15} color={colors.primaryForeground} />
<Text style={[styles.saveText, { color: colors.primaryForeground }]}> <Text style={[styles.saveText, { color: colors.primaryForeground }]}>
Save Settings {form.id ? "Update" : "Save"}
</Text> </Text>
</> </>
)} )}
</TouchableOpacity> </TouchableOpacity>
</View>
{isConfigured && ( </View>
<TouchableOpacity
onPress={handleClear}
activeOpacity={0.8}
style={[styles.clearBtn, { borderColor: colors.destructive + "60" }]}
>
<Feather name="log-out" size={14} color={colors.destructive} />
<Text style={[styles.clearText, { color: colors.destructive }]}>Disconnect</Text>
</TouchableOpacity>
)} )}
<View style={styles.footer}>
<Text style={[styles.footerText, { color: colors.mutedForeground }]}> <Text style={[styles.footerText, { color: colors.mutedForeground }]}>
Your API key is stored securely on this device and never transmitted to third parties. API keys are stored securely on this device and never transmitted to third parties.
</Text> </Text>
</View>
</KeyboardAwareScrollView> </KeyboardAwareScrollView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: { paddingHorizontal: 20, gap: 14 },
paddingHorizontal: 20,
gap: 16,
},
banner: { banner: {
flexDirection: "row", flexDirection: "row", alignItems: "center", gap: 10,
alignItems: "center", paddingHorizontal: 14, paddingVertical: 12, borderRadius: 12, borderWidth: 1,
gap: 10,
paddingHorizontal: 14,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
},
bannerText: {
fontSize: 13,
fontFamily: "Inter_500Medium",
flex: 1,
}, },
bannerText: { fontSize: 13, fontFamily: "Inter_500Medium", flex: 1 },
connectedBadge: { connectedBadge: {
flexDirection: "row", flexDirection: "row", alignItems: "center", gap: 6,
alignItems: "center", paddingHorizontal: 12, paddingVertical: 8, borderRadius: 10, borderWidth: 1, alignSelf: "flex-start",
gap: 6,
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 10,
borderWidth: 1,
alignSelf: "flex-start",
}, },
connectedText: { connectedText: { fontSize: 12, fontFamily: "Inter_600SemiBold" },
fontSize: 12, wsCard: { borderRadius: 14, borderWidth: 1, overflow: "hidden" },
fontFamily: "Inter_600SemiBold", wsCardHeader: { flexDirection: "row", alignItems: "center", paddingHorizontal: 14, paddingVertical: 12 },
}, wsCardLeft: { flex: 1, flexDirection: "row", alignItems: "center", gap: 12 },
section: { wsIcon: { width: 32, height: 32, borderRadius: 10, alignItems: "center", justifyContent: "center" },
gap: 8, wsName: { fontSize: 14, fontFamily: "Inter_600SemiBold" },
}, wsUrl: { fontSize: 11, fontFamily: "Inter_400Regular", marginTop: 1 },
label: { wsCardActions: { flexDirection: "row", gap: 4 },
fontSize: 11, iconBtn: { padding: 8 },
fontFamily: "Inter_600SemiBold", addBtn: {
letterSpacing: 0.8, flexDirection: "row", alignItems: "center", justifyContent: "center",
marginLeft: 2, gap: 8, paddingVertical: 13, borderRadius: 14, borderWidth: 1, borderStyle: "dashed",
}, },
addBtnText: { fontSize: 14, fontFamily: "Inter_600SemiBold" },
formCard: { borderRadius: 14, borderWidth: 1, padding: 16, gap: 14 },
formTitle: { fontSize: 15, fontFamily: "Inter_600SemiBold" },
fieldGroup: { gap: 6 },
label: { fontSize: 11, fontFamily: "Inter_600SemiBold", letterSpacing: 0.8, marginLeft: 2 },
inputWrap: { inputWrap: {
flexDirection: "row", flexDirection: "row", alignItems: "center",
alignItems: "center", borderRadius: 10, borderWidth: 1, paddingHorizontal: 12, paddingVertical: 11, gap: 10,
borderRadius: 12,
borderWidth: 1,
paddingHorizontal: 14,
paddingVertical: 12,
gap: 10,
},
inputIcon: {
flexShrink: 0,
},
input: {
flex: 1,
fontSize: 14,
fontFamily: "Inter_400Regular",
},
validationRow: {
flexDirection: "row",
alignItems: "center",
gap: 6,
marginLeft: 2,
},
validationText: {
fontSize: 12,
fontFamily: "Inter_400Regular",
},
errorBox: {
borderRadius: 10,
borderWidth: 1,
padding: 12,
gap: 6,
},
errorHeader: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
errorTitle: {
fontSize: 12,
fontFamily: "Inter_600SemiBold",
},
errorScroll: {
maxHeight: 80,
},
errorDetail: {
fontSize: 11,
fontFamily: "Inter_400Regular",
lineHeight: 16,
}, },
input: { flex: 1, fontSize: 14, fontFamily: "Inter_400Regular" },
validRow: { flexDirection: "row", alignItems: "center", gap: 6, marginLeft: 2 },
validText: { fontSize: 12, fontFamily: "Inter_400Regular" },
errorBox: { borderRadius: 10, borderWidth: 1, padding: 12, gap: 6 },
errorHeader: { flexDirection: "row", alignItems: "center", gap: 6 },
errorTitle: { fontSize: 12, fontFamily: "Inter_600SemiBold" },
errorScroll: { maxHeight: 80 },
errorDetail: { fontSize: 11, fontFamily: "Inter_400Regular", lineHeight: 16 },
validateBtn: { validateBtn: {
flexDirection: "row", flexDirection: "row", alignItems: "center", justifyContent: "center",
alignItems: "center", gap: 8, paddingVertical: 11, borderRadius: 10, borderWidth: 1,
justifyContent: "center",
gap: 8,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
},
validateText: {
fontSize: 14,
fontFamily: "Inter_600SemiBold",
}, },
validateText: { fontSize: 14, fontFamily: "Inter_600SemiBold" },
formBtnsRow: { flexDirection: "row", gap: 10 },
cancelBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, borderWidth: 1, alignItems: "center" },
cancelText: { fontSize: 14, fontFamily: "Inter_500Medium" },
saveBtn: { saveBtn: {
flexDirection: "row", flex: 2, flexDirection: "row", alignItems: "center", justifyContent: "center",
alignItems: "center", gap: 8, paddingVertical: 12, borderRadius: 10,
justifyContent: "center",
gap: 8,
paddingVertical: 14,
borderRadius: 14,
},
saveText: {
fontSize: 15,
fontFamily: "Inter_600SemiBold",
},
clearBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
},
clearText: {
fontSize: 14,
fontFamily: "Inter_500Medium",
},
footer: {
marginTop: 8,
},
footerText: {
fontSize: 12,
fontFamily: "Inter_400Regular",
textAlign: "center",
lineHeight: 18,
}, },
saveText: { fontSize: 14, fontFamily: "Inter_600SemiBold" },
footerText: { fontSize: 12, fontFamily: "Inter_400Regular", textAlign: "center", lineHeight: 18, marginTop: 4 },
}); });
+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 />
Binary file not shown.

Before

Width:  |  Height:  |  Size: 652 KiB

After

Width:  |  Height:  |  Size: 49 KiB

+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 ""
@@ -0,0 +1,238 @@
import { Feather } from "@expo/vector-icons";
import { Image } from "expo-image";
import React, { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
FlatList,
Modal,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { PostizWorkspace } from "@/context/PostizContext";
import { useColors } from "@/hooks/useColors";
export interface LibraryMediaItem {
id: string;
path: string;
workspaceId: string;
createdAt?: string;
}
interface RawMediaItem {
id: string;
path: string;
createdAt?: string;
}
interface Props {
visible: boolean;
workspaces: PostizWorkspace[];
defaultWorkspaceId?: string;
maxSelect: number;
onClose: () => void;
onSelect: (items: LibraryMediaItem[]) => void;
}
function resolveUrl(path: string, baseUrl: string): string {
if (path.startsWith("http://") || path.startsWith("https://")) return path;
const origin = baseUrl.replace(/\/api\/.*$/, "");
return `${origin}/${path.replace(/^\//, "")}`;
}
export function MediaLibraryModal({ visible, workspaces, defaultWorkspaceId, maxSelect, onClose, onSelect }: Props) {
const colors = useColors();
const insets = useSafeAreaInsets();
const [activeId, setActiveId] = useState<string>("");
const [items, setItems] = useState<RawMediaItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const activeWorkspace = workspaces.find((w) => w.id === activeId) ?? workspaces[0] ?? null;
useEffect(() => {
if (visible) {
const initial = defaultWorkspaceId ?? workspaces[0]?.id ?? "";
setActiveId(initial);
setSelected(new Set());
}
}, [visible, defaultWorkspaceId, workspaces]);
const load = useCallback(async () => {
if (!activeWorkspace) return;
setLoading(true);
setError(null);
try {
// eslint-disable-next-line no-undef
const res = await globalThis.fetch(`${activeWorkspace.baseUrl}/media`, {
headers: { Authorization: activeWorkspace.apiKey },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const list: RawMediaItem[] = Array.isArray(data)
? data
: (data?.media ?? data?.items ?? data?.files ?? []);
setItems(list);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to load media");
} finally {
setLoading(false);
}
}, [activeWorkspace]);
useEffect(() => {
if (visible && activeWorkspace) {
setSelected(new Set());
load();
}
}, [visible, activeWorkspace, load]);
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) { next.delete(id); }
else if (next.size < maxSelect) { next.add(id); }
return next;
});
};
const handleConfirm = () => {
if (!activeWorkspace) return;
const chosen = items
.filter((i) => selected.has(i.id))
.map((i): LibraryMediaItem => ({ ...i, workspaceId: activeWorkspace.id }));
onSelect(chosen);
};
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
<View style={[styles.root, { backgroundColor: colors.background, paddingTop: insets.top }]}>
{/* Header */}
<View style={[styles.header, { borderBottomColor: colors.border }]}>
<TouchableOpacity onPress={onClose} activeOpacity={0.7} style={styles.closeBtn}>
<Feather name="x" size={20} color={colors.foreground} />
</TouchableOpacity>
<Text style={[styles.title, { color: colors.foreground }]}>Media Library</Text>
<TouchableOpacity
onPress={handleConfirm}
disabled={selected.size === 0}
activeOpacity={0.8}
style={[styles.addBtn, { backgroundColor: selected.size > 0 ? colors.primary : colors.muted }]}
>
<Text style={[styles.addBtnText, { color: colors.primaryForeground }]}>
{selected.size > 0 ? `Add ${selected.size}` : "Add"}
</Text>
</TouchableOpacity>
</View>
{/* Workspace tabs (only shown when >1 workspace) */}
{workspaces.length > 1 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={[styles.tabs, { borderBottomColor: colors.border }]}
contentContainerStyle={styles.tabsContent}
>
{workspaces.map((ws) => {
const active = ws.id === activeId;
return (
<TouchableOpacity
key={ws.id}
onPress={() => setActiveId(ws.id)}
activeOpacity={0.7}
style={[
styles.tab,
active && { borderBottomColor: colors.primary, borderBottomWidth: 2 },
]}
>
<Text style={[styles.tabText, { color: active ? colors.primary : colors.mutedForeground }]}>
{ws.name}
</Text>
</TouchableOpacity>
);
})}
</ScrollView>
)}
{/* Content */}
{loading ? (
<View style={styles.centered}>
<ActivityIndicator color={colors.primary} size="large" />
</View>
) : error ? (
<View style={styles.centered}>
<Feather name="alert-circle" size={28} color={colors.error} />
<Text style={[styles.errorText, { color: colors.mutedForeground }]}>{error}</Text>
<TouchableOpacity onPress={load} style={[styles.retryBtn, { backgroundColor: colors.primary }]} activeOpacity={0.8}>
<Text style={[styles.retryText, { color: colors.primaryForeground }]}>Retry</Text>
</TouchableOpacity>
</View>
) : items.length === 0 ? (
<View style={styles.centered}>
<Feather name="image" size={36} color={colors.mutedForeground} />
<Text style={[styles.emptyText, { color: colors.mutedForeground }]}>No media found</Text>
</View>
) : (
<FlatList
data={items}
keyExtractor={(item) => item.id}
numColumns={3}
contentContainerStyle={[styles.grid, { paddingBottom: insets.bottom + 16 }]}
renderItem={({ item }) => {
const isSelected = selected.has(item.id);
const uri = resolveUrl(item.path, activeWorkspace?.baseUrl ?? "");
return (
<TouchableOpacity onPress={() => toggle(item.id)} activeOpacity={0.8} style={styles.cell}>
<Image source={{ uri }} style={styles.cellImage} contentFit="cover" />
{isSelected && (
<View style={[styles.selectedOverlay, { backgroundColor: colors.primary + "99" }]}>
<View style={[styles.checkCircle, { backgroundColor: colors.primary }]}>
<Feather name="check" size={14} color="#fff" />
</View>
</View>
)}
</TouchableOpacity>
);
}}
/>
)}
</View>
</Modal>
);
}
const CELL = 120;
const styles = StyleSheet.create({
root: { flex: 1 },
header: {
flexDirection: "row", alignItems: "center",
paddingHorizontal: 16, paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth, gap: 12,
},
closeBtn: { padding: 4 },
title: { flex: 1, fontSize: 17, fontFamily: "Inter_600SemiBold" },
addBtn: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: 20 },
addBtnText: { fontSize: 14, fontFamily: "Inter_600SemiBold" },
tabs: { borderBottomWidth: StyleSheet.hairlineWidth, flexGrow: 0 },
tabsContent: { paddingHorizontal: 12, gap: 4 },
tab: { paddingHorizontal: 12, paddingVertical: 12 },
tabText: { fontSize: 13, fontFamily: "Inter_500Medium" },
centered: { flex: 1, alignItems: "center", justifyContent: "center", gap: 12 },
errorText: { fontSize: 14, fontFamily: "Inter_400Regular", textAlign: "center", paddingHorizontal: 32 },
emptyText: { fontSize: 14, fontFamily: "Inter_400Regular" },
retryBtn: { paddingHorizontal: 20, paddingVertical: 10, borderRadius: 10 },
retryText: { fontSize: 14, fontFamily: "Inter_600SemiBold" },
grid: { padding: 2 },
cell: { width: CELL, height: CELL, margin: 2 },
cellImage: { width: CELL, height: CELL, borderRadius: 4 },
selectedOverlay: {
...StyleSheet.absoluteFillObject, borderRadius: 4,
alignItems: "center", justifyContent: "center",
},
checkCircle: { width: 28, height: 28, borderRadius: 14, alignItems: "center", justifyContent: "center" },
});
@@ -12,11 +12,14 @@ import {
import { Swipeable } from "react-native-gesture-handler"; import { Swipeable } from "react-native-gesture-handler";
import { useColors } from "@/hooks/useColors"; import { useColors } from "@/hooks/useColors";
import { PostizPost } from "@/context/PostizContext"; import { PostizPost } from "@/context/PostizContext";
import { stripHtml } from "@/lib/stripHtml";
import { StatusBadge } from "./StatusBadge"; import { StatusBadge } from "./StatusBadge";
interface PostCardProps { interface PostCardProps {
post: PostizPost; post: PostizPost;
onDelete: (id: string) => Promise<void>; onDelete: (id: string) => Promise<void>;
onLongPress: (post: PostizPost) => void;
onReschedule?: (post: PostizPost) => void;
} }
function formatDate(dateStr: string): string { function formatDate(dateStr: string): string {
@@ -43,7 +46,7 @@ function getNetworkIcon(type?: string): React.ComponentProps<typeof Feather>["na
return "globe"; return "globe";
} }
export function PostCard({ post, onDelete }: 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);
@@ -87,20 +90,54 @@ export function PostCard({ post, onDelete }: 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 plainContent = stripHtml(post.content);
const truncatedContent = const truncatedContent =
post.content.length > 140 plainContent.length > 140
? post.content.slice(0, 140) + "…" ? plainContent.slice(0, 140) + "…"
: post.content; : plainContent;
return ( return (
<Swipeable <Swipeable
ref={swipeRef} ref={swipeRef}
renderRightActions={renderRightActions} renderRightActions={renderRightActions}
renderLeftActions={renderLeftActions}
rightThreshold={40} rightThreshold={40}
leftThreshold={40}
friction={2} friction={2}
> >
<View <TouchableOpacity
activeOpacity={0.85}
onLongPress={() => onLongPress(post)}
delayLongPress={400}
style={[ style={[
styles.card, styles.card,
{ backgroundColor: colors.card, borderBottomColor: colors.border }, { backgroundColor: colors.card, borderBottomColor: colors.border },
@@ -135,12 +172,25 @@ export function PostCard({ post, onDelete }: 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)}
</Text> </Text>
</View> </View>
</View> </TouchableOpacity>
</Swipeable> </Swipeable>
); );
} }
@@ -187,9 +237,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",
},
}); });
@@ -8,9 +8,18 @@ import React, {
useState, useState,
} from "react"; } from "react";
const API_KEY_STORAGE = "postiz_api_key"; const WORKSPACES_KEY = "postiz_workspaces_v2";
const BASE_URL_STORAGE = "postiz_base_url"; const LEGACY_API_KEY = "postiz_api_key";
const DEFAULT_BASE_URL = "https://postiz.gyozamancave.fr/api/public/v1"; const LEGACY_BASE_URL = "postiz_base_url";
export const DEFAULT_BASE_URL = "https://postiz.gyozamancave.fr/api/public/v1";
export interface PostizWorkspace {
id: string;
name: string;
apiKey: string;
baseUrl: string;
}
export interface PostizIntegration { export interface PostizIntegration {
id: string; id: string;
@@ -35,6 +44,7 @@ export interface PostizPost {
integrations?: PostizIntegration[]; integrations?: PostizIntegration[];
image?: PostizMediaItem[]; image?: PostizMediaItem[];
group?: string; group?: string;
errorMessage?: string;
} }
export interface PostizUploadResult { export interface PostizUploadResult {
@@ -43,58 +53,79 @@ export interface PostizUploadResult {
} }
interface PostizContextValue { interface PostizContextValue {
apiKey: string; workspaces: PostizWorkspace[];
baseUrl: string;
isConfigured: boolean; isConfigured: boolean;
isLoading: boolean; isLoading: boolean;
clients: Record<string, AxiosInstance>;
addWorkspace: (ws: Omit<PostizWorkspace, "id">) => Promise<void>;
updateWorkspace: (ws: PostizWorkspace) => Promise<void>;
removeWorkspace: (id: string) => Promise<void>;
// backward compat for posts.tsx (first workspace)
client: AxiosInstance | null; client: AxiosInstance | null;
saveSettings: (apiKey: string, baseUrl: string) => Promise<void>; apiKey: string;
clearSettings: () => Promise<void>; baseUrl: string;
} }
const PostizContext = createContext<PostizContextValue>({ const PostizContext = createContext<PostizContextValue>({
apiKey: "", workspaces: [],
baseUrl: DEFAULT_BASE_URL,
isConfigured: false, isConfigured: false,
isLoading: true, isLoading: true,
clients: {},
addWorkspace: async () => {},
updateWorkspace: async () => {},
removeWorkspace: async () => {},
client: null, client: null,
saveSettings: async () => {}, apiKey: "",
clearSettings: async () => {}, baseUrl: DEFAULT_BASE_URL,
}); });
function createClient(apiKey: string, baseUrl: string): AxiosInstance { function makeClient(ws: PostizWorkspace): AxiosInstance {
const normalizedUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"; const baseURL = ws.baseUrl.endsWith("/") ? ws.baseUrl : ws.baseUrl + "/";
const instance = axios.create({ const instance = axios.create({
baseURL: normalizedUrl, baseURL,
headers: { headers: { Authorization: ws.apiKey, "Content-Type": "application/json" },
Authorization: apiKey,
"Content-Type": "application/json",
},
timeout: 15000, timeout: 15000,
}); });
instance.interceptors.request.use((config) => { instance.interceptors.request.use((config) => {
console.log(">>> REQUEST:", config.method?.toUpperCase(), (config.baseURL || "") + (config.url || "")); console.log(">>> REQUEST:", config.method?.toUpperCase(), (config.baseURL ?? "") + (config.url ?? ""));
return config; return config;
}); });
return instance; return instance;
} }
function buildClients(list: PostizWorkspace[]): Record<string, AxiosInstance> {
return Object.fromEntries(list.map((ws) => [ws.id, makeClient(ws)]));
}
export function PostizProvider({ children }: { children: React.ReactNode }) { export function PostizProvider({ children }: { children: React.ReactNode }) {
const [apiKey, setApiKey] = useState(""); const [workspaces, setWorkspaces] = useState<PostizWorkspace[]>([]);
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL); const [clients, setClients] = useState<Record<string, AxiosInstance>>({});
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [client, setClient] = useState<AxiosInstance | null>(null);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
const storedKey = await SecureStore.getItemAsync(API_KEY_STORAGE); const stored = await SecureStore.getItemAsync(WORKSPACES_KEY);
const storedUrl = await SecureStore.getItemAsync(BASE_URL_STORAGE); if (stored) {
if (storedKey) { const list: PostizWorkspace[] = JSON.parse(stored);
const url = (storedUrl || DEFAULT_BASE_URL).replace(/\/$/, ""); setWorkspaces(list);
setApiKey(storedKey); setClients(buildClients(list));
setBaseUrl(url); } else {
setClient(() => createClient(storedKey, url)); // Migrate legacy single-workspace config
const legacyKey = await SecureStore.getItemAsync(LEGACY_API_KEY);
const legacyUrl = await SecureStore.getItemAsync(LEGACY_BASE_URL);
if (legacyKey) {
const migrated: PostizWorkspace = {
id: Date.now().toString(),
name: "Default",
apiKey: legacyKey,
baseUrl: (legacyUrl || DEFAULT_BASE_URL).replace(/\/$/, ""),
};
const list = [migrated];
await SecureStore.setItemAsync(WORKSPACES_KEY, JSON.stringify(list));
setWorkspaces(list);
setClients(buildClients(list));
}
} }
} catch { } catch {
} finally { } finally {
@@ -103,35 +134,48 @@ export function PostizProvider({ children }: { children: React.ReactNode }) {
})(); })();
}, []); }, []);
const saveSettings = useCallback( const persist = useCallback(async (list: PostizWorkspace[]) => {
async (newApiKey: string, newBaseUrl: string) => { await SecureStore.setItemAsync(WORKSPACES_KEY, JSON.stringify(list));
await SecureStore.setItemAsync(API_KEY_STORAGE, newApiKey); setWorkspaces(list);
await SecureStore.setItemAsync(BASE_URL_STORAGE, newBaseUrl); setClients(buildClients(list));
setApiKey(newApiKey); }, []);
setBaseUrl(newBaseUrl);
setClient(() => createClient(newApiKey, newBaseUrl)); const addWorkspace = useCallback(
async (ws: Omit<PostizWorkspace, "id">) => {
await persist([...workspaces, { ...ws, id: Date.now().toString() }]);
}, },
[] [workspaces, persist]
); );
const clearSettings = useCallback(async () => { const updateWorkspace = useCallback(
await SecureStore.deleteItemAsync(API_KEY_STORAGE); async (ws: PostizWorkspace) => {
await SecureStore.deleteItemAsync(BASE_URL_STORAGE); await persist(workspaces.map((w) => (w.id === ws.id ? ws : w)));
setApiKey(""); },
setBaseUrl(DEFAULT_BASE_URL); [workspaces, persist]
setClient(null); );
}, []);
const removeWorkspace = useCallback(
async (id: string) => {
await persist(workspaces.filter((w) => w.id !== id));
},
[workspaces, persist]
);
const primaryWorkspace = workspaces[0] ?? null;
return ( return (
<PostizContext.Provider <PostizContext.Provider
value={{ value={{
apiKey, workspaces,
baseUrl, isConfigured: workspaces.length > 0,
isConfigured: !!apiKey,
isLoading, isLoading,
client, clients,
saveSettings, addWorkspace,
clearSettings, updateWorkspace,
removeWorkspace,
client: primaryWorkspace ? (clients[primaryWorkspace.id] ?? null) : null,
apiKey: primaryWorkspace?.apiKey ?? "",
baseUrl: primaryWorkspace?.baseUrl ?? DEFAULT_BASE_URL,
}} }}
> >
{children} {children}
+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": {}
} }
} }
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { usePostiz } from "@/context/PostizContext"; import { usePostiz } from "@/context/PostizContext";
import { PostizPost } from "@/context/PostizContext"; import { PostizPost } from "@/context/PostizContext";
import { stripHtml } from "@/lib/stripHtml";
const POLL_INTERVAL_MS = 15 * 60 * 1000; const POLL_INTERVAL_MS = 15 * 60 * 1000;
const SEEN_KEY = "postiz_seen_statuses"; const SEEN_KEY = "postiz_seen_statuses";
@@ -44,10 +45,7 @@ async function sendStatusNotification(post: PostizPost) {
await Notifications.scheduleNotificationAsync({ await Notifications.scheduleNotificationAsync({
content: { content: {
title: isError ? "Post failed to publish" : "Post published!", title: isError ? "Post failed to publish" : "Post published!",
body: body: (() => { const t = stripHtml(post.content); return t.length > 80 ? t.slice(0, 80) + "…" : t; })(),
post.content.length > 80
? post.content.slice(0, 80) + "…"
: post.content,
data: { postId: post.id }, data: { postId: post.id },
}, },
trigger: null, trigger: null,
+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";
}
+19
View File
@@ -0,0 +1,19 @@
export function stripHtml(html: string): string {
// Decode entities first so encoded tags like &lt;p&gt; are also stripped
let s = html
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ");
// Block-level tags → newlines
s = s
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n")
.replace(/<\/div>/gi, "\n")
.replace(/<\/li>/gi, "\n");
// Strip all remaining tags
s = s.replace(/<[^>]+>/g, "");
return s.replace(/\n{3,}/g, "\n\n").trim();
}
+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;
+12 -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",
@@ -58,9 +62,12 @@
"dependencies": { "dependencies": {
"@react-native-community/datetimepicker": "8.4.4", "@react-native-community/datetimepicker": "8.4.4",
"axios": "^1.15.2", "axios": "^1.15.2",
"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) {
+52 -21
View File
@@ -398,15 +398,15 @@ importers:
axios: axios:
specifier: ^1.15.2 specifier: ^1.15.2
version: 1.15.2 version: 1.15.2
expo-clipboard:
specifier: ~8.0.8
version: 8.0.8(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo-notifications: expo-notifications:
specifier: ~0.32.17 specifier: ~0.32.17
version: 0.32.17(expo@54.0.34)(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: 0.32.17(expo@54.0.34)(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-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)
@@ -450,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
@@ -3384,6 +3387,13 @@ packages:
react: '*' react: '*'
react-native: '*' react-native: '*'
expo-clipboard@8.0.8:
resolution: {integrity: sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA==}
peerDependencies:
expo: '*'
react: '*'
react-native: '*'
expo-constants@18.0.13: expo-constants@18.0.13:
resolution: {integrity: sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==} resolution: {integrity: sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==}
peerDependencies: peerDependencies:
@@ -3546,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:
@@ -5571,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'}
@@ -8773,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
@@ -9386,6 +9419,12 @@ snapshots:
react: 19.1.0 react: 19.1.0
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
expo-clipboard@8.0.8(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(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: 19.1.0
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
expo-constants@18.0.13(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)): expo-constants@18.0.13(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/config': 12.0.13 '@expo/config': 12.0.13
@@ -9569,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)
@@ -11918,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."