Compare commits
6 commits
main
...
web-redesi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f653b083aa | ||
|
|
f5b620111b | ||
|
|
cf432bd803 | ||
|
|
55825662d7 | ||
|
|
71e39c7e88 | ||
|
|
7ab6b72866 |
13
.env.example
|
|
@ -1,5 +1,5 @@
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Altricade Messenger — environment template.
|
# Zovi Messenger — environment template.
|
||||||
# Copy to `.env` (gitignored) and replace every value below.
|
# Copy to `.env` (gitignored) and replace every value below.
|
||||||
# The values here are CLEARLY-FAKE dev placeholders — NEVER use in production.
|
# The values here are CLEARLY-FAKE dev placeholders — NEVER use in production.
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
@ -54,9 +54,12 @@ MINIO_PORT=9000
|
||||||
MINIO_USE_SSL=false
|
MINIO_USE_SSL=false
|
||||||
MINIO_BUCKET_MEDIA=media
|
MINIO_BUCKET_MEDIA=media
|
||||||
MINIO_BUCKET_AVATARS=avatars
|
MINIO_BUCKET_AVATARS=avatars
|
||||||
# Browser-facing MinIO URL — presigned upload/download URLs are signed for this
|
# Fallback public MinIO base URL. nginx proxies /media/ and /avatars/ at the
|
||||||
# host, so it must match what the browser uses (dev: the exposed host port).
|
# gateway, and presigned URLs are normally signed for whatever origin the
|
||||||
MINIO_PUBLIC_URL=http://localhost:9000
|
# request arrived on (localhost for a browser, your LAN IP for a phone) — this
|
||||||
|
# value is used for persisted avatar URLs and as the presign fallback, so point
|
||||||
|
# it at the gateway origin phones can reach (e.g. http://<LAN-IP>:8080).
|
||||||
|
MINIO_PUBLIC_URL=http://localhost:8080
|
||||||
# S3 region used for SigV4 presigning (MinIO default is us-east-1). Set explicitly
|
# S3 region used for SigV4 presigning (MinIO default is us-east-1). Set explicitly
|
||||||
# so presigning never makes a network region-lookup call.
|
# so presigning never makes a network region-lookup call.
|
||||||
MINIO_REGION=us-east-1
|
MINIO_REGION=us-east-1
|
||||||
|
|
@ -67,7 +70,7 @@ MINIO_REGION=us-east-1
|
||||||
# The public key is safe to expose to the browser; keep the private key secret.
|
# The public key is safe to expose to the browser; keep the private key secret.
|
||||||
VAPID_PUBLIC_KEY=
|
VAPID_PUBLIC_KEY=
|
||||||
VAPID_PRIVATE_KEY=
|
VAPID_PRIVATE_KEY=
|
||||||
VAPID_SUBJECT=mailto:admin@altricade.com
|
VAPID_SUBJECT=mailto:admin@zovi.app
|
||||||
# Firebase Cloud Messaging (Android now, iOS later — same code path). Point this
|
# Firebase Cloud Messaging (Android now, iOS later — same code path). Point this
|
||||||
# at the service-account JSON mounted into the worker; leave empty to disable FCM.
|
# at the service-account JSON mounted into the worker; leave empty to disable FCM.
|
||||||
FCM_SERVICE_ACCOUNT_FILE=
|
FCM_SERVICE_ACCOUNT_FILE=
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Altricade Messenger
|
# Zovi Messenger
|
||||||
|
|
||||||
A self-hosted, cross-platform realtime messenger (chat + later calls). TypeScript
|
A self-hosted, cross-platform realtime messenger (chat + later calls). TypeScript
|
||||||
everywhere, pnpm monorepo. See `specs.md` for the full architecture brief.
|
everywhere, pnpm monorepo. See `specs.md` for the full architecture brief.
|
||||||
|
|
@ -71,6 +71,7 @@ Open the Swagger UI, then:
|
||||||
3. Protected endpoints (`/me`, `/auth/sessions`, `/auth/centrifugo-token`, …) now work from "Try it out".
|
3. Protected endpoints (`/me`, `/auth/sessions`, `/auth/centrifugo-token`, …) now work from "Try it out".
|
||||||
|
|
||||||
> Dev notes:
|
> Dev notes:
|
||||||
|
>
|
||||||
> - The backend runs in Docker with `node_modules` baked into the image. After
|
> - The backend runs in Docker with `node_modules` baked into the image. After
|
||||||
> changing backend dependencies, recreate the container so it picks them up:
|
> changing backend dependencies, recreate the container so it picks them up:
|
||||||
> `docker compose up -d --build --force-recreate backend`.
|
> `docker compose up -d --build --force-recreate backend`.
|
||||||
|
|
|
||||||
36
assets/brand/README.md
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# Zovi brand assets
|
||||||
|
|
||||||
|
Master artwork is SVG; everything else is generated from it. Brand accent: `#4c6fff`
|
||||||
|
(gradient `#6c8cff → #4c6fff → #3843e8`, 135°). The glyph is the "bubble Z" — a Z whose
|
||||||
|
bottom-left tail makes it read as a speech bubble.
|
||||||
|
|
||||||
|
## Masters
|
||||||
|
|
||||||
|
| File | What it is |
|
||||||
|
| --- | --- |
|
||||||
|
| `icon.svg` | Full-bleed square icon (gradient + white glyph). Source for iOS/Android/web PNGs. |
|
||||||
|
| `icon-rounded.svg` | Same with 22.4% corner radius baked in. Source for Windows `.ico`. |
|
||||||
|
| `icon-macos.svg` | Big Sur style: rounded body at 824/1024 grid with transparent margin. Source for `.icns`. |
|
||||||
|
| `favicon.svg` | Flat `#4c6fff`, glyph enlarged 14% for 16–32px legibility. Shipped as-is on web. |
|
||||||
|
| `adaptive-icon-foreground.svg` | White glyph inside Android's 66/108 safe circle, transparent bg. |
|
||||||
|
| `mark-gradient.svg` | Gradient glyph, no background — for docs/marketing on light surfaces. |
|
||||||
|
| `splash-light.svg` / `splash-dark.svg` | 2048×2048 splash, centered rounded icon on `#ffffff` / `#0c0d10`. |
|
||||||
|
|
||||||
|
## Exports → where they go
|
||||||
|
|
||||||
|
- `exports/ios/icon-1024.png` — Expo `app.json` → `expo.icon` (square; iOS rounds it).
|
||||||
|
- `exports/android/icon-1024.png` — legacy Android icon (`expo.android.icon`).
|
||||||
|
- `exports/android/adaptive-icon-foreground.png` — `expo.android.adaptiveIcon.foregroundImage`,
|
||||||
|
with `backgroundColor: "#4c6fff"` (or regenerate a gradient background layer).
|
||||||
|
- `exports/splash/splash-light.png` + `splash-dark.png` — `expo.splash.image` /
|
||||||
|
`expo.splash.dark.image`, `resizeMode: "contain"`, background `#ffffff` / `#0c0d10`.
|
||||||
|
- `exports/macos/zovi.icns` — Tauri `bundle.icon` (macOS).
|
||||||
|
- `exports/windows/zovi.ico` — Tauri `bundle.icon` (Windows); also usable for taskbar/installer.
|
||||||
|
- Web copies live in `packages/web/public/` (`favicon.svg`, `favicon.ico`, `icons/*`,
|
||||||
|
`manifest.webmanifest`) — already wired into `index.html`.
|
||||||
|
|
||||||
|
## Regenerating
|
||||||
|
|
||||||
|
PNGs: `npx sharp-cli -i <master>.svg -o out.png resize <w> <h>`.
|
||||||
|
ICO: `npx png-to-ico <pngs…> > out.ico` (pass a real 256px render).
|
||||||
|
ICNS: build an `.iconset` (16…1024 incl. @2x) from `icon-macos.svg`, then `iconutil -c icns`.
|
||||||
6
assets/brand/adaptive-icon-foreground.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 512 512">
|
||||||
|
<!-- Android adaptive foreground: glyph kept inside the 66/108 safe circle -->
|
||||||
|
<g transform="translate(256 256) scale(0.63) translate(-256 -256)">
|
||||||
|
<path fill="#ffffff" d="M118 124H394V208L268 304H394V388H196L118 458V304L244 208H118Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 348 B |
BIN
assets/brand/exports/android/adaptive-icon-foreground.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
assets/brand/exports/android/icon-1024.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
assets/brand/exports/ios/icon-1024.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
assets/brand/exports/macos/zovi.icns
Normal file
BIN
assets/brand/exports/splash/splash-dark.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
assets/brand/exports/splash/splash-light.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
assets/brand/exports/windows/zovi.ico
Normal file
|
After Width: | Height: | Size: 279 KiB |
7
assets/brand/favicon.svg
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<!-- Flat accent + slightly enlarged glyph: tuned for 16-32px legibility -->
|
||||||
|
<rect width="512" height="512" rx="102" fill="#4c6fff"/>
|
||||||
|
<g transform="translate(256 256) scale(1.14) translate(-256 -256)">
|
||||||
|
<path fill="#ffffff" d="M118 124H394V208L268 304H394V388H196L118 458V304L244 208H118Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 403 B |
14
assets/brand/icon-macos.svg
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="zovi-bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#6c8cff"/>
|
||||||
|
<stop offset="0.48" stop-color="#4c6fff"/>
|
||||||
|
<stop offset="1" stop-color="#3843e8"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Big Sur grid: icon body is 824/1024 of the canvas with transparent margin -->
|
||||||
|
<rect x="50" y="50" width="412" height="412" rx="92" fill="url(#zovi-bg)"/>
|
||||||
|
<g transform="translate(256 256) scale(0.805) translate(-256 -256)">
|
||||||
|
<path fill="#ffffff" d="M118 124H394V208L268 304H394V388H196L118 458V304L244 208H118Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 675 B |
11
assets/brand/icon-rounded.svg
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="zovi-bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#6c8cff"/>
|
||||||
|
<stop offset="0.48" stop-color="#4c6fff"/>
|
||||||
|
<stop offset="1" stop-color="#3843e8"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="115" fill="url(#zovi-bg)"/>
|
||||||
|
<path fill="#ffffff" d="M118 124H394V208L268 304H394V388H196L118 458V304L244 208H118Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 497 B |
11
assets/brand/icon.svg
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="zovi-bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#6c8cff"/>
|
||||||
|
<stop offset="0.48" stop-color="#4c6fff"/>
|
||||||
|
<stop offset="1" stop-color="#3843e8"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" fill="url(#zovi-bg)"/>
|
||||||
|
<path fill="#ffffff" d="M118 124H394V208L268 304H394V388H196L118 458V304L244 208H118Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 488 B |
9
assets/brand/mark-gradient.svg
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="zovi-mark" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#5f7fff"/>
|
||||||
|
<stop offset="1" stop-color="#3d4ef0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path fill="url(#zovi-mark)" d="M118 124H394V208L268 304H394V388H196L118 458V304L244 208H118Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
14
assets/brand/splash-dark.svg
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="2048" height="2048" viewBox="0 0 2048 2048">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="zovi-bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#6c8cff"/>
|
||||||
|
<stop offset="0.48" stop-color="#4c6fff"/>
|
||||||
|
<stop offset="1" stop-color="#3843e8"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="2048" height="2048" fill="#0c0d10"/>
|
||||||
|
<g transform="translate(768 768)">
|
||||||
|
<rect width="512" height="512" rx="115" fill="url(#zovi-bg)"/>
|
||||||
|
<path fill="#ffffff" d="M118 124H394V208L268 304H394V388H196L118 458V304L244 208H118Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 599 B |
14
assets/brand/splash-light.svg
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="2048" height="2048" viewBox="0 0 2048 2048">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="zovi-bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#6c8cff"/>
|
||||||
|
<stop offset="0.48" stop-color="#4c6fff"/>
|
||||||
|
<stop offset="1" stop-color="#3843e8"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="2048" height="2048" fill="#ffffff"/>
|
||||||
|
<g transform="translate(768 768)">
|
||||||
|
<rect width="512" height="512" rx="115" fill="url(#zovi-bg)"/>
|
||||||
|
<path fill="#ffffff" d="M118 124H394V208L268 304H394V388H196L118 458V304L244 208H118Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 599 B |
|
|
@ -137,6 +137,10 @@ services:
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:1.27-alpine
|
image: nginx:1.27-alpine
|
||||||
|
# Restart on failure: a concurrent stack restart can start nginx before the
|
||||||
|
# backend's DNS entry exists ("host not found in upstream"), which is fatal
|
||||||
|
# at config load — retrying once dependencies are up self-heals the gateway.
|
||||||
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
- ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ http {
|
||||||
upstream centrifugo {
|
upstream centrifugo {
|
||||||
server centrifugo:8000;
|
server centrifugo:8000;
|
||||||
}
|
}
|
||||||
|
upstream minio {
|
||||||
|
server minio:9000;
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
|
|
@ -20,14 +23,33 @@ http {
|
||||||
absolute_redirect off;
|
absolute_redirect off;
|
||||||
|
|
||||||
# REST API — strip the /api prefix before proxying to the backend.
|
# REST API — strip the /api prefix before proxying to the backend.
|
||||||
|
# $http_host (not $host) keeps the port: the backend presigns media URLs
|
||||||
|
# for the exact origin the client used to reach this gateway.
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend/;
|
proxy_pass http://backend/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Object storage through the same origin as the API, so presigned URLs work
|
||||||
|
# from any client that can reach this gateway (browsers AND phones). Paths
|
||||||
|
# match the bucket names (media, avatars). Host must be preserved verbatim —
|
||||||
|
# S3 v4 signatures cover it.
|
||||||
|
location /media/ {
|
||||||
|
proxy_pass http://minio;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_buffering off;
|
||||||
|
client_max_body_size 200m;
|
||||||
|
}
|
||||||
|
location /avatars/ {
|
||||||
|
proxy_pass http://minio;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_buffering off;
|
||||||
|
client_max_body_size 25m;
|
||||||
|
}
|
||||||
|
|
||||||
# Swagger UI + OpenAPI spec — served by the backend at /docs (HTML, static
|
# Swagger UI + OpenAPI spec — served by the backend at /docs (HTML, static
|
||||||
# assets and /docs/json all live under this prefix; pass through unmodified).
|
# assets and /docs/json all live under this prefix; pass through unmodified).
|
||||||
# Redirect the slashless form so the UI's relative asset paths resolve.
|
# Redirect the slashless form so the UI's relative asset paths resolve.
|
||||||
|
|
@ -55,7 +77,7 @@ http {
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
default_type text/plain;
|
default_type text/plain;
|
||||||
return 200 "Altricade gateway — /api/* -> backend, /connection/websocket -> centrifugo\n";
|
return 200 "Zovi gateway — /api/* -> backend, /connection/websocket -> centrifugo\n";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "altricade-messenger",
|
"name": "zovi-messenger",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
// Phase 7 UX — per-user deletion modes, per-user chat state, folders + pins.
|
||||||
|
//
|
||||||
|
// All of this is per-user *view* state (Telegram semantics): hiding a message
|
||||||
|
// or clearing a chat never mutates the shared message rows; it records what
|
||||||
|
// this user no longer sees. Folders and pins are per-user and server-stored so
|
||||||
|
// they sync across devices via the personal channel.
|
||||||
|
|
||||||
|
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||||
|
exports.up = (pgm) => {
|
||||||
|
// "Delete for me": tombstone per (user, message). History queries anti-join.
|
||||||
|
pgm.createTable('message_hidden', {
|
||||||
|
user_id: { type: 'uuid', notNull: true, references: 'users', onDelete: 'CASCADE' },
|
||||||
|
message_id: { type: 'uuid', notNull: true, references: 'messages', onDelete: 'CASCADE' },
|
||||||
|
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
});
|
||||||
|
pgm.addConstraint('message_hidden', 'message_hidden_pkey', {
|
||||||
|
primaryKey: ['user_id', 'message_id'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// "Clear history" / "delete chat" for me. Messages with seq <= cleared_up_to_seq
|
||||||
|
// are invisible to this member. hidden_at removes the chat from the list until
|
||||||
|
// new activity arrives (last_message_at > hidden_at brings it back).
|
||||||
|
pgm.addColumns('conversation_members', {
|
||||||
|
cleared_up_to_seq: { type: 'bigint', notNull: true, default: 0 },
|
||||||
|
hidden_at: { type: 'timestamptz' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manual chat folders (per user), ordered by position.
|
||||||
|
pgm.createTable('chat_folders', {
|
||||||
|
id: { type: 'uuid', notNull: true, default: pgm.func('gen_random_uuid()'), primaryKey: true },
|
||||||
|
user_id: { type: 'uuid', notNull: true, references: 'users', onDelete: 'CASCADE' },
|
||||||
|
title: { type: 'text', notNull: true },
|
||||||
|
position: { type: 'integer', notNull: true, default: 0 },
|
||||||
|
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
});
|
||||||
|
pgm.createIndex('chat_folders', 'user_id');
|
||||||
|
|
||||||
|
pgm.createTable('chat_folder_items', {
|
||||||
|
folder_id: { type: 'uuid', notNull: true, references: 'chat_folders', onDelete: 'CASCADE' },
|
||||||
|
conversation_id: {
|
||||||
|
type: 'uuid',
|
||||||
|
notNull: true,
|
||||||
|
references: 'conversations',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
},
|
||||||
|
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
});
|
||||||
|
pgm.addConstraint('chat_folder_items', 'chat_folder_items_pkey', {
|
||||||
|
primaryKey: ['folder_id', 'conversation_id'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pins are scoped: folder_id NULL = pinned in the "All chats" tab. Pinning in
|
||||||
|
// one folder deliberately does not pin anywhere else.
|
||||||
|
pgm.createTable('chat_pins', {
|
||||||
|
user_id: { type: 'uuid', notNull: true, references: 'users', onDelete: 'CASCADE' },
|
||||||
|
conversation_id: {
|
||||||
|
type: 'uuid',
|
||||||
|
notNull: true,
|
||||||
|
references: 'conversations',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
},
|
||||||
|
folder_id: { type: 'uuid', references: 'chat_folders', onDelete: 'CASCADE' },
|
||||||
|
pinned_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
});
|
||||||
|
// Uniqueness needs two partial indexes because folder_id is nullable.
|
||||||
|
pgm.createIndex('chat_pins', ['user_id', 'conversation_id'], {
|
||||||
|
name: 'chat_pins_all_scope_unique',
|
||||||
|
unique: true,
|
||||||
|
where: 'folder_id IS NULL',
|
||||||
|
});
|
||||||
|
pgm.createIndex('chat_pins', ['user_id', 'conversation_id', 'folder_id'], {
|
||||||
|
name: 'chat_pins_folder_scope_unique',
|
||||||
|
unique: true,
|
||||||
|
where: 'folder_id IS NOT NULL',
|
||||||
|
});
|
||||||
|
pgm.createIndex('chat_pins', 'user_id');
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||||
|
exports.down = (pgm) => {
|
||||||
|
pgm.dropTable('chat_pins');
|
||||||
|
pgm.dropTable('chat_folder_items');
|
||||||
|
pgm.dropTable('chat_folders');
|
||||||
|
pgm.dropColumns('conversation_members', ['cleared_up_to_seq', 'hidden_at']);
|
||||||
|
pgm.dropTable('message_hidden');
|
||||||
|
};
|
||||||
15
packages/backend/migrations/1720000000008_channels.cjs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// Channels — broadcast conversations (type='channel') where only the owner and
|
||||||
|
// admins post. `type` is a plain text column, so the new value needs no DDL;
|
||||||
|
// channels just add an optional description shown on the info page.
|
||||||
|
|
||||||
|
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||||
|
exports.up = (pgm) => {
|
||||||
|
pgm.addColumns('conversations', {
|
||||||
|
description: { type: 'text' },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||||
|
exports.down = (pgm) => {
|
||||||
|
pgm.dropColumns('conversations', ['description']);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
// Group/channel avatars — stores the public avatar URL (same convention as
|
||||||
|
// users.avatar_ref), null when unset.
|
||||||
|
|
||||||
|
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||||
|
exports.up = (pgm) => {
|
||||||
|
pgm.addColumns('conversations', {
|
||||||
|
image_ref: { type: 'text' },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||||
|
exports.down = (pgm) => {
|
||||||
|
pgm.dropColumns('conversations', ['image_ref']);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
// Reply, forward attribution, and (multiple) pinned messages.
|
||||||
|
|
||||||
|
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||||
|
exports.up = (pgm) => {
|
||||||
|
pgm.addColumns('messages', {
|
||||||
|
// Reply target; if the replied message is hard-deleted the link clears.
|
||||||
|
reply_to_id: { type: 'uuid', references: 'messages', onDelete: 'SET NULL' },
|
||||||
|
// Whether this message is a forward, and a snapshot of the original author
|
||||||
|
// (PublicUser jsonb) — null when the forwarder chose to hide the origin.
|
||||||
|
forwarded: { type: 'boolean', notNull: true, default: false },
|
||||||
|
forwarded_from: { type: 'jsonb' },
|
||||||
|
});
|
||||||
|
pgm.createIndex('messages', 'reply_to_id');
|
||||||
|
|
||||||
|
// Pinned messages (multiple per conversation), newest pin first.
|
||||||
|
pgm.createTable('message_pins', {
|
||||||
|
conversation_id: {
|
||||||
|
type: 'uuid',
|
||||||
|
notNull: true,
|
||||||
|
references: 'conversations',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
},
|
||||||
|
message_id: { type: 'uuid', notNull: true, references: 'messages', onDelete: 'CASCADE' },
|
||||||
|
pinned_by: { type: 'uuid', notNull: true, references: 'users', onDelete: 'CASCADE' },
|
||||||
|
pinned_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||||
|
});
|
||||||
|
pgm.addConstraint('message_pins', 'message_pins_pkey', {
|
||||||
|
primaryKey: ['conversation_id', 'message_id'],
|
||||||
|
});
|
||||||
|
pgm.createIndex('message_pins', ['conversation_id', 'pinned_at']);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||||
|
exports.down = (pgm) => {
|
||||||
|
pgm.dropTable('message_pins');
|
||||||
|
pgm.dropColumns('messages', ['reply_to_id', 'forwarded', 'forwarded_from']);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
// forwarded_from jsonb shape change: was a bare PublicUser snapshot, now a
|
||||||
|
// ForwardOrigin {name, user} so channel forwards can carry a name without a
|
||||||
|
// profile link. Rewrites existing rows in place (guarded, idempotent).
|
||||||
|
|
||||||
|
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||||
|
exports.up = (pgm) => {
|
||||||
|
pgm.sql(`
|
||||||
|
UPDATE messages
|
||||||
|
SET forwarded_from = jsonb_build_object(
|
||||||
|
'name', forwarded_from->>'displayName',
|
||||||
|
'user', forwarded_from
|
||||||
|
)
|
||||||
|
WHERE forwarded_from IS NOT NULL
|
||||||
|
AND forwarded_from ? 'displayName'
|
||||||
|
AND NOT (forwarded_from ? 'user');
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||||
|
exports.down = (pgm) => {
|
||||||
|
pgm.sql(`
|
||||||
|
UPDATE messages
|
||||||
|
SET forwarded_from = forwarded_from->'user'
|
||||||
|
WHERE forwarded_from IS NOT NULL
|
||||||
|
AND forwarded_from ? 'user'
|
||||||
|
AND forwarded_from->'user' IS NOT NULL;
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
} from './modules/conversations';
|
} from './modules/conversations';
|
||||||
import { createMessagesRepository, createMessagesService, messagesRoutes } from './modules/messages';
|
import { createMessagesRepository, createMessagesService, messagesRoutes } from './modules/messages';
|
||||||
import { createContactsRepository, createContactsService, contactsRoutes } from './modules/contacts';
|
import { createContactsRepository, createContactsService, contactsRoutes } from './modules/contacts';
|
||||||
|
import { createFoldersRepository, createFoldersService, foldersRoutes } from './modules/folders';
|
||||||
import { createPresenceService, presenceRoutes } from './modules/presence';
|
import { createPresenceService, presenceRoutes } from './modules/presence';
|
||||||
import { createMediaService, mediaRoutes } from './modules/media';
|
import { createMediaService, mediaRoutes } from './modules/media';
|
||||||
import {
|
import {
|
||||||
|
|
@ -98,7 +99,7 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
|
||||||
const contactsRepository = createContactsRepository(app.db);
|
const contactsRepository = createContactsRepository(app.db);
|
||||||
const deliver = createDeliver(conversationsRepository, publish);
|
const deliver = createDeliver(conversationsRepository, publish);
|
||||||
const mediaService = createMediaService({
|
const mediaService = createMediaService({
|
||||||
minio: app.minioPublic,
|
presignClient: app.minioPresign,
|
||||||
mediaBucket: config.minio.buckets.media,
|
mediaBucket: config.minio.buckets.media,
|
||||||
avatarsBucket: config.minio.buckets.avatars,
|
avatarsBucket: config.minio.buckets.avatars,
|
||||||
publicUrl: config.minio.publicUrl,
|
publicUrl: config.minio.publicUrl,
|
||||||
|
|
@ -155,7 +156,8 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
|
||||||
messages: messagesRepository,
|
messages: messagesRepository,
|
||||||
conversations: conversationsRepository,
|
conversations: conversationsRepository,
|
||||||
deliver,
|
deliver,
|
||||||
mediaDownloadUrl: (objectKey) => mediaService.downloadUrl(objectKey),
|
publish,
|
||||||
|
mediaDownloadUrl: (objectKey, origin) => mediaService.downloadUrl(objectKey, origin),
|
||||||
notify: (message) => {
|
notify: (message) => {
|
||||||
void notificationQueue
|
void notificationQueue
|
||||||
.enqueueMessage({
|
.enqueueMessage({
|
||||||
|
|
@ -173,6 +175,14 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
|
||||||
'contactsService',
|
'contactsService',
|
||||||
createContactsService({ contacts: contactsRepository, users: usersRepository }),
|
createContactsService({ contacts: contactsRepository, users: usersRepository }),
|
||||||
);
|
);
|
||||||
|
app.decorate(
|
||||||
|
'foldersService',
|
||||||
|
createFoldersService({
|
||||||
|
folders: createFoldersRepository(app.db),
|
||||||
|
conversations: conversationsRepository,
|
||||||
|
publish,
|
||||||
|
}),
|
||||||
|
);
|
||||||
app.decorate(
|
app.decorate(
|
||||||
'presenceService',
|
'presenceService',
|
||||||
createPresenceService({
|
createPresenceService({
|
||||||
|
|
@ -191,6 +201,7 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
|
||||||
await app.register(conversationsRoutes);
|
await app.register(conversationsRoutes);
|
||||||
await app.register(messagesRoutes);
|
await app.register(messagesRoutes);
|
||||||
await app.register(contactsRoutes);
|
await app.register(contactsRoutes);
|
||||||
|
await app.register(foldersRoutes);
|
||||||
await app.register(presenceRoutes);
|
await app.register(presenceRoutes);
|
||||||
await app.register(mediaRoutes);
|
await app.register(mediaRoutes);
|
||||||
await app.register(notificationsRoutes);
|
await app.register(notificationsRoutes);
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ const buildNotificationsConfig = (): NotificationsConfig => {
|
||||||
const privateKey = optional('VAPID_PRIVATE_KEY', '');
|
const privateKey = optional('VAPID_PRIVATE_KEY', '');
|
||||||
const webPush =
|
const webPush =
|
||||||
publicKey !== '' && privateKey !== ''
|
publicKey !== '' && privateKey !== ''
|
||||||
? { publicKey, privateKey, subject: optional('VAPID_SUBJECT', 'mailto:admin@altricade.com') }
|
? { publicKey, privateKey, subject: optional('VAPID_SUBJECT', 'mailto:admin@zovi.app') }
|
||||||
: null;
|
: null;
|
||||||
const fcmFile = optional('FCM_SERVICE_ACCOUNT_FILE', '');
|
const fcmFile = optional('FCM_SERVICE_ACCOUNT_FILE', '');
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { ColumnType, Generated } from 'kysely';
|
import type { ColumnType, Generated } from 'kysely';
|
||||||
import type { MediaRef } from '@altricade/core';
|
import type { MediaRef, ForwardOrigin } from '@altricade/core';
|
||||||
|
|
||||||
// Kysely database registry: one interface per table. Grows with each migration.
|
// Kysely database registry: one interface per table. Grows with each migration.
|
||||||
|
|
||||||
|
|
@ -33,6 +33,10 @@ export interface ConversationsTable {
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
type: Generated<string>;
|
type: Generated<string>;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
|
/** Optional channel description. */
|
||||||
|
description: string | null;
|
||||||
|
/** Group/channel avatar public URL (null when unset). */
|
||||||
|
image_ref: string | null;
|
||||||
direct_key: string | null;
|
direct_key: string | null;
|
||||||
created_by: string;
|
created_by: string;
|
||||||
created_at: Generated<Date>;
|
created_at: Generated<Date>;
|
||||||
|
|
@ -44,6 +48,10 @@ export interface ConversationMembersTable {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
role: Generated<string>;
|
role: Generated<string>;
|
||||||
joined_at: Generated<Date>;
|
joined_at: Generated<Date>;
|
||||||
|
// Per-user view state: messages with seq <= cleared_up_to_seq are invisible;
|
||||||
|
// hidden_at removes the chat from the list until new activity arrives.
|
||||||
|
cleared_up_to_seq: Generated<number>;
|
||||||
|
hidden_at: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessagesTable {
|
export interface MessagesTable {
|
||||||
|
|
@ -62,6 +70,17 @@ export interface MessagesTable {
|
||||||
media_key: string | null;
|
media_key: string | null;
|
||||||
// jsonb: parsed to a MediaRef on read, JSON string on write.
|
// jsonb: parsed to a MediaRef on read, JSON string on write.
|
||||||
media_meta: ColumnType<MediaRef | null, string | null, string | null>;
|
media_meta: ColumnType<MediaRef | null, string | null, string | null>;
|
||||||
|
reply_to_id: string | null;
|
||||||
|
forwarded: Generated<boolean>;
|
||||||
|
// jsonb: a ForwardOrigin snapshot ({name, user}), or null (hidden / none).
|
||||||
|
forwarded_from: ColumnType<ForwardOrigin | null, string | null, string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessagePinsTable {
|
||||||
|
conversation_id: string;
|
||||||
|
message_id: string;
|
||||||
|
pinned_by: string;
|
||||||
|
pinned_at: Generated<Date>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContactsTable {
|
export interface ContactsTable {
|
||||||
|
|
@ -114,6 +133,35 @@ export interface ConversationMutesTable {
|
||||||
created_at: Generated<Date>;
|
created_at: Generated<Date>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Delete for me" tombstones — history queries anti-join against this.
|
||||||
|
export interface MessageHiddenTable {
|
||||||
|
user_id: string;
|
||||||
|
message_id: string;
|
||||||
|
created_at: Generated<Date>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatFoldersTable {
|
||||||
|
id: Generated<string>;
|
||||||
|
user_id: string;
|
||||||
|
title: string;
|
||||||
|
position: Generated<number>;
|
||||||
|
created_at: Generated<Date>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatFolderItemsTable {
|
||||||
|
folder_id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
created_at: Generated<Date>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pin scope: folder_id NULL = the "All chats" tab. Per-folder pins are independent.
|
||||||
|
export interface ChatPinsTable {
|
||||||
|
user_id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
folder_id: string | null;
|
||||||
|
pinned_at: Generated<Date>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Database {
|
export interface Database {
|
||||||
users: UsersTable;
|
users: UsersTable;
|
||||||
refresh_tokens: RefreshTokensTable;
|
refresh_tokens: RefreshTokensTable;
|
||||||
|
|
@ -126,4 +174,9 @@ export interface Database {
|
||||||
device_tokens: DeviceTokensTable;
|
device_tokens: DeviceTokensTable;
|
||||||
notification_settings: NotificationSettingsTable;
|
notification_settings: NotificationSettingsTable;
|
||||||
conversation_mutes: ConversationMutesTable;
|
conversation_mutes: ConversationMutesTable;
|
||||||
|
message_hidden: MessageHiddenTable;
|
||||||
|
chat_folders: ChatFoldersTable;
|
||||||
|
chat_folder_items: ChatFolderItemsTable;
|
||||||
|
chat_pins: ChatPinsTable;
|
||||||
|
message_pins: MessagePinsTable;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ export interface RefreshTokensRepository {
|
||||||
findByHash(tokenHash: string): Promise<RefreshTokenRow | undefined>;
|
findByHash(tokenHash: string): Promise<RefreshTokenRow | undefined>;
|
||||||
revokeById(id: string): Promise<void>;
|
revokeById(id: string): Promise<void>;
|
||||||
revokeFamily(familyId: string): Promise<void>;
|
revokeFamily(familyId: string): Promise<void>;
|
||||||
|
/** True when the family still has a live (unrevoked, unexpired) token. */
|
||||||
|
hasActiveInFamily(familyId: string): Promise<boolean>;
|
||||||
revokeAllForUser(userId: string): Promise<void>;
|
revokeAllForUser(userId: string): Promise<void>;
|
||||||
listActiveForUser(userId: string): Promise<RefreshTokenRow[]>;
|
listActiveForUser(userId: string): Promise<RefreshTokenRow[]>;
|
||||||
}
|
}
|
||||||
|
|
@ -50,6 +52,18 @@ export const createRefreshTokensRepository = (
|
||||||
.execute();
|
.execute();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
hasActiveInFamily: async (familyId) => {
|
||||||
|
const row = await db
|
||||||
|
.selectFrom('refresh_tokens')
|
||||||
|
.select('id')
|
||||||
|
.where('family_id', '=', familyId)
|
||||||
|
.where('revoked_at', 'is', null)
|
||||||
|
.where('expires_at', '>', new Date())
|
||||||
|
.limit(1)
|
||||||
|
.executeTakeFirst();
|
||||||
|
return row !== undefined;
|
||||||
|
},
|
||||||
|
|
||||||
revokeFamily: async (familyId) => {
|
revokeFamily: async (familyId) => {
|
||||||
await db
|
await db
|
||||||
.updateTable('refresh_tokens')
|
.updateTable('refresh_tokens')
|
||||||
|
|
|
||||||
|
|
@ -12,18 +12,40 @@ import type { IssuedAuth, RequestContext } from './auth.service';
|
||||||
|
|
||||||
const REFRESH_COOKIE = 'refresh_token';
|
const REFRESH_COOKIE = 'refresh_token';
|
||||||
|
|
||||||
|
// Native clients (React Native) can't rely on cookies, so they send
|
||||||
|
// `X-Auth-Mode: token`; the backend then returns the refresh token in the body
|
||||||
|
// and reads it from the body instead of the cookie.
|
||||||
|
const isTokenMode = (request: FastifyRequest): boolean =>
|
||||||
|
request.headers['x-auth-mode'] === 'token';
|
||||||
|
|
||||||
const context = (request: FastifyRequest): RequestContext => ({
|
const context = (request: FastifyRequest): RequestContext => ({
|
||||||
userAgent: request.headers['user-agent'] ?? null,
|
userAgent: request.headers['user-agent'] ?? null,
|
||||||
ip: request.ip,
|
ip: request.ip,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Public response body — deliberately omits the refresh token (cookie only).
|
// Body shape carrying the refresh token in token mode (register/login omit it).
|
||||||
const publicResult = (issued: IssuedAuth): AuthResult => ({
|
const refreshTokenFromBody = (body: unknown): string | undefined => {
|
||||||
|
if (typeof body === 'object' && body !== null && 'refreshToken' in body) {
|
||||||
|
const value: unknown = body.refreshToken;
|
||||||
|
return typeof value === 'string' ? value : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Public response body — cookie mode omits the refresh token; token mode includes it.
|
||||||
|
const authResult = (issued: IssuedAuth, tokenMode: boolean): AuthResult => ({
|
||||||
user: issued.user,
|
user: issued.user,
|
||||||
accessToken: issued.accessToken,
|
accessToken: issued.accessToken,
|
||||||
accessTokenExpiresIn: issued.accessTokenExpiresIn,
|
accessTokenExpiresIn: issued.accessTokenExpiresIn,
|
||||||
|
...(tokenMode ? { refreshToken: issued.refreshToken } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const refreshBodySchema = {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: { refreshToken: { type: 'string' } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
// Throttle credential endpoints to blunt stuffing / enumeration.
|
// Throttle credential endpoints to blunt stuffing / enumeration.
|
||||||
const authRateLimit = { rateLimit: { max: 10, timeWindow: '1 minute' } };
|
const authRateLimit = { rateLimit: { max: 10, timeWindow: '1 minute' } };
|
||||||
const bearerAuth = [{ bearerAuth: [] }];
|
const bearerAuth = [{ bearerAuth: [] }];
|
||||||
|
|
@ -58,8 +80,11 @@ export const authRoutes = (app: FastifyInstance): Promise<void> => {
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const issued = await app.authService.register(request.body, context(request));
|
const issued = await app.authService.register(request.body, context(request));
|
||||||
|
const tokenMode = isTokenMode(request);
|
||||||
|
if (!tokenMode) {
|
||||||
setRefreshCookie(reply, issued.refreshToken);
|
setRefreshCookie(reply, issued.refreshToken);
|
||||||
return reply.code(201).send(publicResult(issued));
|
}
|
||||||
|
return reply.code(201).send(authResult(issued, tokenMode));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -76,37 +101,54 @@ export const authRoutes = (app: FastifyInstance): Promise<void> => {
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const issued = await app.authService.login(request.body, context(request));
|
const issued = await app.authService.login(request.body, context(request));
|
||||||
|
const tokenMode = isTokenMode(request);
|
||||||
|
if (!tokenMode) {
|
||||||
setRefreshCookie(reply, issued.refreshToken);
|
setRefreshCookie(reply, issued.refreshToken);
|
||||||
return reply.send(publicResult(issued));
|
}
|
||||||
|
return reply.send(authResult(issued, tokenMode));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
app.post(
|
app.post<{ Body?: { refreshToken?: string } }>(
|
||||||
'/refresh',
|
'/refresh',
|
||||||
{
|
{
|
||||||
schema: {
|
schema: {
|
||||||
tags: ['auth'],
|
tags: ['auth'],
|
||||||
summary: 'Rotate tokens using the refresh cookie (reuse detection)',
|
summary: 'Rotate tokens using the refresh cookie or body token (reuse detection)',
|
||||||
|
body: refreshBodySchema,
|
||||||
response: { 200: authResultSchema, 401: errorSchema },
|
response: { 200: authResultSchema, 401: errorSchema },
|
||||||
},
|
},
|
||||||
config: authRateLimit,
|
config: authRateLimit,
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const token = request.cookies[REFRESH_COOKIE];
|
const tokenMode = isTokenMode(request);
|
||||||
|
const token = tokenMode
|
||||||
|
? refreshTokenFromBody(request.body)
|
||||||
|
: request.cookies[REFRESH_COOKIE];
|
||||||
if (token === undefined) {
|
if (token === undefined) {
|
||||||
return reply.code(401).send({ error: 'invalid_token', message: 'Missing refresh token' });
|
return reply.code(401).send({ error: 'invalid_token', message: 'Missing refresh token' });
|
||||||
}
|
}
|
||||||
const issued = await app.authService.refresh(token, context(request));
|
const issued = await app.authService.refresh(token, context(request));
|
||||||
|
if (!tokenMode) {
|
||||||
setRefreshCookie(reply, issued.refreshToken);
|
setRefreshCookie(reply, issued.refreshToken);
|
||||||
return reply.send(publicResult(issued));
|
}
|
||||||
|
return reply.send(authResult(issued, tokenMode));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
app.post(
|
app.post<{ Body?: { refreshToken?: string } }>(
|
||||||
'/logout',
|
'/logout',
|
||||||
{ schema: { tags: ['auth'], summary: 'Revoke the current refresh token (this device)' } },
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['auth'],
|
||||||
|
summary: 'Revoke the current refresh token (this device)',
|
||||||
|
body: refreshBodySchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const token = request.cookies[REFRESH_COOKIE];
|
const token = isTokenMode(request)
|
||||||
|
? refreshTokenFromBody(request.body)
|
||||||
|
: request.cookies[REFRESH_COOKIE];
|
||||||
if (token !== undefined) {
|
if (token !== undefined) {
|
||||||
await app.authService.logout(token);
|
await app.authService.logout(token);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -135,9 +135,19 @@ export const createAuthService = (deps: AuthServiceDeps): AuthService => {
|
||||||
throw new HttpError(401, 'invalid_token', 'Invalid refresh token');
|
throw new HttpError(401, 'invalid_token', 'Invalid refresh token');
|
||||||
}
|
}
|
||||||
if (row.revoked_at !== null) {
|
if (row.revoked_at !== null) {
|
||||||
|
// Grace window: the same cookie replayed moments after rotation is a
|
||||||
|
// parallel client (second tab, double-mounted bootstrap), not theft.
|
||||||
|
// Re-issue within the family instead of nuking it. The grace only
|
||||||
|
// applies while the family still has a live successor — a family
|
||||||
|
// killed by reuse-detection or logout stays dead, so genuine theft
|
||||||
|
// can't ride the window back in.
|
||||||
|
const graceMs = 30_000;
|
||||||
|
const withinGrace = Date.now() - row.revoked_at.getTime() <= graceMs;
|
||||||
|
if (!withinGrace || !(await tokens.hasActiveInFamily(row.family_id))) {
|
||||||
await tokens.revokeFamily(row.family_id);
|
await tokens.revokeFamily(row.family_id);
|
||||||
throw new HttpError(401, 'token_reuse', 'Refresh token reuse detected');
|
throw new HttpError(401, 'token_reuse', 'Refresh token reuse detected');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (row.expires_at.getTime() <= Date.now()) {
|
if (row.expires_at.getTime() <= Date.now()) {
|
||||||
throw new HttpError(401, 'invalid_token', 'Refresh token expired');
|
throw new HttpError(401, 'invalid_token', 'Refresh token expired');
|
||||||
}
|
}
|
||||||
|
|
@ -145,7 +155,9 @@ export const createAuthService = (deps: AuthServiceDeps): AuthService => {
|
||||||
if (user === undefined) {
|
if (user === undefined) {
|
||||||
throw new HttpError(401, 'invalid_token', 'Invalid refresh token');
|
throw new HttpError(401, 'invalid_token', 'Invalid refresh token');
|
||||||
}
|
}
|
||||||
|
if (row.revoked_at === null) {
|
||||||
await tokens.revokeById(row.id);
|
await tokens.revokeById(row.id);
|
||||||
|
}
|
||||||
return issue(user, ctx, row.family_id);
|
return issue(user, ctx, row.family_id);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,15 @@
|
||||||
import type { Conversation, ConversationMember, PublicUser } from '@altricade/core';
|
import type {
|
||||||
import type { ConversationRow, MemberWithUser } from './conversations.repository';
|
Conversation,
|
||||||
|
ConversationMember,
|
||||||
|
LastMessagePreview,
|
||||||
|
MediaKind,
|
||||||
|
PublicUser,
|
||||||
|
} from '@altricade/core';
|
||||||
|
import type {
|
||||||
|
ConversationRow,
|
||||||
|
ConversationListRow,
|
||||||
|
MemberWithUser,
|
||||||
|
} from './conversations.repository';
|
||||||
|
|
||||||
export const memberToPublicUser = (member: MemberWithUser): PublicUser => ({
|
export const memberToPublicUser = (member: MemberWithUser): PublicUser => ({
|
||||||
id: member.user_id,
|
id: member.user_id,
|
||||||
|
|
@ -8,18 +18,42 @@ export const memberToPublicUser = (member: MemberWithUser): PublicUser => ({
|
||||||
avatarUrl: member.avatar_ref,
|
avatarUrl: member.avatar_ref,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const MEDIA_KINDS: readonly MediaKind[] = ['image', 'video', 'video_note', 'voice', 'file'];
|
||||||
|
|
||||||
|
const toMediaKind = (value: string | null): MediaKind | null =>
|
||||||
|
MEDIA_KINDS.find((kind) => kind === value) ?? null;
|
||||||
|
|
||||||
|
export const toLastMessagePreview = (row: ConversationListRow): LastMessagePreview | null => {
|
||||||
|
if (row.last_msg_sender_id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const deleted = row.last_msg_deleted_at !== null;
|
||||||
|
return {
|
||||||
|
senderId: row.last_msg_sender_id,
|
||||||
|
senderName: row.last_msg_sender_name ?? '',
|
||||||
|
content: deleted ? '' : (row.last_msg_content ?? ''),
|
||||||
|
mediaKind: deleted ? null : toMediaKind(row.last_msg_media_kind),
|
||||||
|
deleted,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const toConversation = (
|
export const toConversation = (
|
||||||
row: ConversationRow,
|
row: ConversationRow,
|
||||||
peer: PublicUser | null,
|
peer: PublicUser | null,
|
||||||
unreadCount = 0,
|
unreadCount = 0,
|
||||||
|
lastMessage: LastMessagePreview | null = null,
|
||||||
): Conversation => ({
|
): Conversation => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
type: row.type === 'direct' ? 'direct' : 'group',
|
type: row.type === 'direct' ? 'direct' : row.type === 'channel' ? 'channel' : 'group',
|
||||||
title: row.title,
|
title: row.title,
|
||||||
|
description: row.description,
|
||||||
|
// For a DM the peer's avatar is the conversation avatar; groups/channels use their own.
|
||||||
|
avatarUrl: peer !== null ? peer.avatarUrl : row.image_ref,
|
||||||
peer,
|
peer,
|
||||||
createdBy: row.created_by,
|
createdBy: row.created_by,
|
||||||
createdAt: row.created_at.toISOString(),
|
createdAt: row.created_at.toISOString(),
|
||||||
lastMessageAt: row.last_message_at.toISOString(),
|
lastMessageAt: row.last_message_at.toISOString(),
|
||||||
|
lastMessage,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,19 @@
|
||||||
|
import { sql } from 'kysely';
|
||||||
import type { Kysely, Selectable } from 'kysely';
|
import type { Kysely, Selectable } from 'kysely';
|
||||||
import type { Database, ConversationsTable } from '../../db/schema';
|
import type { Database, ConversationsTable } from '../../db/schema';
|
||||||
|
|
||||||
export type ConversationRow = Selectable<ConversationsTable>;
|
export type ConversationRow = Selectable<ConversationsTable>;
|
||||||
|
|
||||||
|
// List rows carry the newest message visible to the requesting user (chat-list
|
||||||
|
// preview); all fields are null for an empty (or fully cleared) conversation.
|
||||||
|
export interface ConversationListRow extends ConversationRow {
|
||||||
|
last_msg_sender_id: string | null;
|
||||||
|
last_msg_sender_name: string | null;
|
||||||
|
last_msg_content: string | null;
|
||||||
|
last_msg_media_kind: string | null;
|
||||||
|
last_msg_deleted_at: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MemberWithUser {
|
export interface MemberWithUser {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
|
@ -26,9 +37,15 @@ const directKeyFor = (a: string, b: string): string => (a < b ? `${a}:${b}` : `$
|
||||||
|
|
||||||
export interface ConversationsRepository {
|
export interface ConversationsRepository {
|
||||||
createGroup(title: string, ownerId: string, memberIds: string[]): Promise<ConversationRow>;
|
createGroup(title: string, ownerId: string, memberIds: string[]): Promise<ConversationRow>;
|
||||||
|
createChannel(
|
||||||
|
title: string,
|
||||||
|
description: string | null,
|
||||||
|
ownerId: string,
|
||||||
|
memberIds: string[],
|
||||||
|
): Promise<ConversationRow>;
|
||||||
findOrCreateDirect(userA: string, userB: string): Promise<FindOrCreate>;
|
findOrCreateDirect(userA: string, userB: string): Promise<FindOrCreate>;
|
||||||
findById(id: string): Promise<ConversationRow | undefined>;
|
findById(id: string): Promise<ConversationRow | undefined>;
|
||||||
listForUser(userId: string): Promise<ConversationRow[]>;
|
listForUser(userId: string): Promise<ConversationListRow[]>;
|
||||||
peersForDirect(userId: string, conversationIds: string[]): Promise<Map<string, MemberWithUser>>;
|
peersForDirect(userId: string, conversationIds: string[]): Promise<Map<string, MemberWithUser>>;
|
||||||
getPeer(conversationId: string, userId: string): Promise<MemberWithUser | undefined>;
|
getPeer(conversationId: string, userId: string): Promise<MemberWithUser | undefined>;
|
||||||
isMember(conversationId: string, userId: string): Promise<boolean>;
|
isMember(conversationId: string, userId: string): Promise<boolean>;
|
||||||
|
|
@ -38,9 +55,47 @@ export interface ConversationsRepository {
|
||||||
listMembers(conversationId: string): Promise<MemberWithUser[]>;
|
listMembers(conversationId: string): Promise<MemberWithUser[]>;
|
||||||
getDeliveryInfo(conversationId: string): Promise<DeliveryInfo | undefined>;
|
getDeliveryInfo(conversationId: string): Promise<DeliveryInfo | undefined>;
|
||||||
touchLastMessage(conversationId: string): Promise<void>;
|
touchLastMessage(conversationId: string): Promise<void>;
|
||||||
|
setImage(conversationId: string, imageUrl: string): Promise<ConversationRow | undefined>;
|
||||||
|
/** "Clear history" for me: hide everything up to the current max seq; resets unread. Returns that seq. */
|
||||||
|
clearForUser(conversationId: string, userId: string): Promise<number>;
|
||||||
|
/** "Delete chat" for me: clear + drop from the list until new activity arrives. Returns cleared seq. */
|
||||||
|
hideForUser(conversationId: string, userId: string): Promise<number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createConversationsRepository = (db: Kysely<Database>): ConversationsRepository => {
|
export const createConversationsRepository = (db: Kysely<Database>): ConversationsRepository => {
|
||||||
|
const clearForUser = (conversationId: string, userId: string): Promise<number> =>
|
||||||
|
db.transaction().execute(async (trx) => {
|
||||||
|
const max = await trx
|
||||||
|
.selectFrom('messages')
|
||||||
|
.select((eb) => eb.fn.max('seq').as('max_seq'))
|
||||||
|
.where('conversation_id', '=', conversationId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
const upTo = max?.max_seq ?? 0;
|
||||||
|
await trx
|
||||||
|
.updateTable('conversation_members')
|
||||||
|
.set({ cleared_up_to_seq: sql`greatest(cleared_up_to_seq, ${upTo})` })
|
||||||
|
.where('conversation_id', '=', conversationId)
|
||||||
|
.where('user_id', '=', userId)
|
||||||
|
.execute();
|
||||||
|
// Clearing also zeroes the unread badge.
|
||||||
|
await trx
|
||||||
|
.insertInto('read_state')
|
||||||
|
.values({
|
||||||
|
conversation_id: conversationId,
|
||||||
|
user_id: userId,
|
||||||
|
last_read_seq: upTo,
|
||||||
|
updated_at: new Date(),
|
||||||
|
})
|
||||||
|
.onConflict((oc) =>
|
||||||
|
oc.columns(['conversation_id', 'user_id']).doUpdateSet({
|
||||||
|
last_read_seq: sql`greatest(read_state.last_read_seq, excluded.last_read_seq)`,
|
||||||
|
updated_at: new Date(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
return upTo;
|
||||||
|
});
|
||||||
|
|
||||||
const membersWithUser = (conversationId: string) =>
|
const membersWithUser = (conversationId: string) =>
|
||||||
db
|
db
|
||||||
.selectFrom('conversation_members')
|
.selectFrom('conversation_members')
|
||||||
|
|
@ -80,6 +135,30 @@ export const createConversationsRepository = (db: Kysely<Database>): Conversatio
|
||||||
return conversation;
|
return conversation;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
createChannel: (title, description, ownerId, memberIds) =>
|
||||||
|
db.transaction().execute(async (trx) => {
|
||||||
|
const conversation = await trx
|
||||||
|
.insertInto('conversations')
|
||||||
|
.values({ type: 'channel', title, description, created_by: ownerId })
|
||||||
|
.returningAll()
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
const uniqueOthers = memberIds.filter((id) => id !== ownerId);
|
||||||
|
const rows = [
|
||||||
|
{ conversation_id: conversation.id, user_id: ownerId, role: 'owner' },
|
||||||
|
...uniqueOthers.map((id) => ({
|
||||||
|
conversation_id: conversation.id,
|
||||||
|
user_id: id,
|
||||||
|
role: 'member',
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
await trx
|
||||||
|
.insertInto('conversation_members')
|
||||||
|
.values(rows)
|
||||||
|
.onConflict((oc) => oc.columns(['conversation_id', 'user_id']).doNothing())
|
||||||
|
.execute();
|
||||||
|
return conversation;
|
||||||
|
}),
|
||||||
|
|
||||||
findOrCreateDirect: async (userA, userB) => {
|
findOrCreateDirect: async (userA, userB) => {
|
||||||
const directKey = directKeyFor(userA, userB);
|
const directKey = directKeyFor(userA, userB);
|
||||||
const existing = await db
|
const existing = await db
|
||||||
|
|
@ -130,7 +209,56 @@ export const createConversationsRepository = (db: Kysely<Database>): Conversatio
|
||||||
'conversations.id',
|
'conversations.id',
|
||||||
)
|
)
|
||||||
.where('conversation_members.user_id', '=', userId)
|
.where('conversation_members.user_id', '=', userId)
|
||||||
|
// A chat "deleted for me" stays gone until new activity arrives.
|
||||||
|
.where((eb) =>
|
||||||
|
eb.or([
|
||||||
|
eb('conversation_members.hidden_at', 'is', null),
|
||||||
|
eb('conversations.last_message_at', '>', eb.ref('conversation_members.hidden_at')),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
// Chat-list preview: the newest message this user can still see —
|
||||||
|
// the same visibility rules as history (above the clear marker, not
|
||||||
|
// "deleted for me").
|
||||||
|
.leftJoinLateral(
|
||||||
|
(eb) =>
|
||||||
|
eb
|
||||||
|
.selectFrom('messages')
|
||||||
|
.innerJoin('users', 'users.id', 'messages.sender_id')
|
||||||
|
.whereRef('messages.conversation_id', '=', 'conversations.id')
|
||||||
|
.whereRef('messages.seq', '>', 'conversation_members.cleared_up_to_seq')
|
||||||
|
.where((web) =>
|
||||||
|
web.not(
|
||||||
|
web.exists(
|
||||||
|
web
|
||||||
|
.selectFrom('message_hidden')
|
||||||
|
.select('message_hidden.message_id')
|
||||||
|
.whereRef('message_hidden.message_id', '=', 'messages.id')
|
||||||
|
.where('message_hidden.user_id', '=', userId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.select((web) => [
|
||||||
|
'messages.sender_id as last_msg_sender_id',
|
||||||
|
'users.display_name as last_msg_sender_name',
|
||||||
|
'messages.content as last_msg_content',
|
||||||
|
web
|
||||||
|
.fn<string | null>('nullif', [sql`messages.media_meta->>'kind'`, sql`''`])
|
||||||
|
.as('last_msg_media_kind'),
|
||||||
|
'messages.deleted_at as last_msg_deleted_at',
|
||||||
|
])
|
||||||
|
.orderBy('messages.seq', 'desc')
|
||||||
|
.limit(1)
|
||||||
|
.as('last_msg'),
|
||||||
|
(join) => join.onTrue(),
|
||||||
|
)
|
||||||
.selectAll('conversations')
|
.selectAll('conversations')
|
||||||
|
.select([
|
||||||
|
'last_msg.last_msg_sender_id',
|
||||||
|
'last_msg.last_msg_sender_name',
|
||||||
|
'last_msg.last_msg_content',
|
||||||
|
'last_msg.last_msg_media_kind',
|
||||||
|
'last_msg.last_msg_deleted_at',
|
||||||
|
])
|
||||||
.orderBy('conversations.last_message_at', 'desc')
|
.orderBy('conversations.last_message_at', 'desc')
|
||||||
.execute(),
|
.execute(),
|
||||||
|
|
||||||
|
|
@ -247,5 +375,26 @@ export const createConversationsRepository = (db: Kysely<Database>): Conversatio
|
||||||
.where('id', '=', conversationId)
|
.where('id', '=', conversationId)
|
||||||
.execute();
|
.execute();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setImage: (conversationId, imageUrl) =>
|
||||||
|
db
|
||||||
|
.updateTable('conversations')
|
||||||
|
.set({ image_ref: imageUrl })
|
||||||
|
.where('id', '=', conversationId)
|
||||||
|
.returningAll()
|
||||||
|
.executeTakeFirst(),
|
||||||
|
|
||||||
|
clearForUser,
|
||||||
|
|
||||||
|
hideForUser: async (conversationId, userId) => {
|
||||||
|
const upTo = await clearForUser(conversationId, userId);
|
||||||
|
await db
|
||||||
|
.updateTable('conversation_members')
|
||||||
|
.set({ hidden_at: new Date() })
|
||||||
|
.where('conversation_id', '=', conversationId)
|
||||||
|
.where('user_id', '=', userId)
|
||||||
|
.execute();
|
||||||
|
return upTo;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,22 @@ import type { FastifyInstance } from 'fastify';
|
||||||
import {
|
import {
|
||||||
createDirectBodySchema,
|
createDirectBodySchema,
|
||||||
createGroupBodySchema,
|
createGroupBodySchema,
|
||||||
|
createChannelBodySchema,
|
||||||
addMemberBodySchema,
|
addMemberBodySchema,
|
||||||
|
setAvatarBodySchema,
|
||||||
readBodySchema,
|
readBodySchema,
|
||||||
conversationSchema,
|
conversationSchema,
|
||||||
conversationListSchema,
|
conversationListSchema,
|
||||||
conversationMemberListSchema,
|
conversationMemberListSchema,
|
||||||
errorSchema,
|
errorSchema,
|
||||||
} from '@altricade/core';
|
} from '@altricade/core';
|
||||||
import type { CreateDirectBody, CreateGroupBody, AddMemberBody, ReadBody } from '@altricade/core';
|
import type {
|
||||||
|
CreateDirectBody,
|
||||||
|
CreateGroupBody,
|
||||||
|
CreateChannelBody,
|
||||||
|
AddMemberBody,
|
||||||
|
ReadBody,
|
||||||
|
} from '@altricade/core';
|
||||||
|
|
||||||
const bearerAuth = [{ bearerAuth: [] }];
|
const bearerAuth = [{ bearerAuth: [] }];
|
||||||
const idParamsSchema = {
|
const idParamsSchema = {
|
||||||
|
|
@ -86,6 +94,31 @@ export const conversationsRoutes = (app: FastifyInstance): Promise<void> => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.post<{ Body: CreateChannelBody }>(
|
||||||
|
'/conversations/channel',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['conversations'],
|
||||||
|
summary: 'Create a channel (broadcast: only owner/admins post)',
|
||||||
|
security: bearerAuth,
|
||||||
|
body: createChannelBodySchema,
|
||||||
|
response: { 201: conversationSchema, 401: errorSchema },
|
||||||
|
},
|
||||||
|
preHandler: app.authenticate,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const user = request.authUser;
|
||||||
|
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
|
const conversation = await app.conversationsService.createChannel(
|
||||||
|
user.id,
|
||||||
|
request.body.title,
|
||||||
|
request.body.description ?? null,
|
||||||
|
request.body.members ?? [],
|
||||||
|
);
|
||||||
|
return reply.code(201).send(conversation);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.get<{ Params: { id: string } }>(
|
app.get<{ Params: { id: string } }>(
|
||||||
'/conversations/:id',
|
'/conversations/:id',
|
||||||
{
|
{
|
||||||
|
|
@ -169,6 +202,75 @@ export const conversationsRoutes = (app: FastifyInstance): Promise<void> => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Body: { objectKey: string } }>(
|
||||||
|
'/conversations/:id/avatar',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['conversations'],
|
||||||
|
summary: 'Set a group/channel photo (owner/admin, from an uploaded object key)',
|
||||||
|
security: bearerAuth,
|
||||||
|
params: idParamsSchema,
|
||||||
|
body: setAvatarBodySchema,
|
||||||
|
response: {
|
||||||
|
200: conversationSchema,
|
||||||
|
400: errorSchema,
|
||||||
|
401: errorSchema,
|
||||||
|
403: errorSchema,
|
||||||
|
404: errorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preHandler: app.authenticate,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const user = request.authUser;
|
||||||
|
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
|
const imageUrl = app.mediaService.avatarPublicUrl(request.body.objectKey);
|
||||||
|
return reply.send(
|
||||||
|
await app.conversationsService.setAvatar(request.params.id, user.id, imageUrl),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/conversations/:id/clear',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['conversations'],
|
||||||
|
summary: 'Clear history for me (chat stays listed)',
|
||||||
|
security: bearerAuth,
|
||||||
|
params: idParamsSchema,
|
||||||
|
response: { 401: errorSchema, 403: errorSchema },
|
||||||
|
},
|
||||||
|
preHandler: app.authenticate,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const user = request.authUser;
|
||||||
|
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
|
await app.conversationsService.clear(request.params.id, user.id);
|
||||||
|
return reply.code(204).send();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/conversations/:id/hide',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['conversations'],
|
||||||
|
summary: 'Delete chat for me (returns on new activity)',
|
||||||
|
security: bearerAuth,
|
||||||
|
params: idParamsSchema,
|
||||||
|
response: { 401: errorSchema, 403: errorSchema },
|
||||||
|
},
|
||||||
|
preHandler: app.authenticate,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const user = request.authUser;
|
||||||
|
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
|
await app.conversationsService.hide(request.params.id, user.id);
|
||||||
|
return reply.code(204).send();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.post<{ Params: { id: string }; Body: ReadBody }>(
|
app.post<{ Params: { id: string }; Body: ReadBody }>(
|
||||||
'/conversations/:id/read',
|
'/conversations/:id/read',
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import type {
|
||||||
ConversationMember,
|
ConversationMember,
|
||||||
ConversationNewEvent,
|
ConversationNewEvent,
|
||||||
ConversationMembershipEvent,
|
ConversationMembershipEvent,
|
||||||
|
ConversationClearedEvent,
|
||||||
|
ConversationHiddenEvent,
|
||||||
ReadReceiptEvent,
|
ReadReceiptEvent,
|
||||||
} from '@altricade/core';
|
} from '@altricade/core';
|
||||||
import { userChannel, EventType } from '@altricade/core';
|
import { userChannel, EventType } from '@altricade/core';
|
||||||
|
|
@ -13,7 +15,12 @@ import type { UsersRepository } from '../users';
|
||||||
import type { ConversationsRepository, DeliveryInfo } from './conversations.repository';
|
import type { ConversationsRepository, DeliveryInfo } from './conversations.repository';
|
||||||
import type { ReadStateRepository } from './read-state.repository';
|
import type { ReadStateRepository } from './read-state.repository';
|
||||||
import type { Deliver } from './delivery';
|
import type { Deliver } from './delivery';
|
||||||
import { toConversation, toMember, memberToPublicUser } from './conversations.mapper';
|
import {
|
||||||
|
toConversation,
|
||||||
|
toLastMessagePreview,
|
||||||
|
toMember,
|
||||||
|
memberToPublicUser,
|
||||||
|
} from './conversations.mapper';
|
||||||
|
|
||||||
export interface ConversationsServiceDeps {
|
export interface ConversationsServiceDeps {
|
||||||
conversations: ConversationsRepository;
|
conversations: ConversationsRepository;
|
||||||
|
|
@ -26,6 +33,12 @@ export interface ConversationsServiceDeps {
|
||||||
export interface ConversationsService {
|
export interface ConversationsService {
|
||||||
createDirect(actorId: string, username: string): Promise<Conversation>;
|
createDirect(actorId: string, username: string): Promise<Conversation>;
|
||||||
createGroup(actorId: string, title: string, memberUsernames: string[]): Promise<Conversation>;
|
createGroup(actorId: string, title: string, memberUsernames: string[]): Promise<Conversation>;
|
||||||
|
createChannel(
|
||||||
|
actorId: string,
|
||||||
|
title: string,
|
||||||
|
description: string | null,
|
||||||
|
memberUsernames: string[],
|
||||||
|
): Promise<Conversation>;
|
||||||
list(userId: string): Promise<Conversation[]>;
|
list(userId: string): Promise<Conversation[]>;
|
||||||
get(conversationId: string, userId: string): Promise<Conversation>;
|
get(conversationId: string, userId: string): Promise<Conversation>;
|
||||||
listMembers(conversationId: string, userId: string): Promise<ConversationMember[]>;
|
listMembers(conversationId: string, userId: string): Promise<ConversationMember[]>;
|
||||||
|
|
@ -34,6 +47,9 @@ export interface ConversationsService {
|
||||||
isMember(conversationId: string, userId: string): Promise<boolean>;
|
isMember(conversationId: string, userId: string): Promise<boolean>;
|
||||||
getDeliveryInfo(conversationId: string): Promise<DeliveryInfo | undefined>;
|
getDeliveryInfo(conversationId: string): Promise<DeliveryInfo | undefined>;
|
||||||
markRead(conversationId: string, userId: string, seq: number): Promise<void>;
|
markRead(conversationId: string, userId: string, seq: number): Promise<void>;
|
||||||
|
clear(conversationId: string, userId: string): Promise<void>;
|
||||||
|
hide(conversationId: string, userId: string): Promise<void>;
|
||||||
|
setAvatar(conversationId: string, actorId: string, imageUrl: string): Promise<Conversation>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createConversationsService = (
|
export const createConversationsService = (
|
||||||
|
|
@ -58,6 +74,18 @@ export const createConversationsService = (
|
||||||
conversation,
|
conversation,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Usernames → ids, dropping unknowns and the actor (who is added as owner).
|
||||||
|
const resolveMemberIds = async (actorId: string, usernames: string[]): Promise<string[]> => {
|
||||||
|
const memberIds: string[] = [];
|
||||||
|
for (const username of usernames) {
|
||||||
|
const found = await users.findByUsername(username);
|
||||||
|
if (found !== undefined && found.id !== actorId) {
|
||||||
|
memberIds.push(found.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return memberIds;
|
||||||
|
};
|
||||||
|
|
||||||
const membership = (
|
const membership = (
|
||||||
action: ConversationMembershipEvent['action'],
|
action: ConversationMembershipEvent['action'],
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
|
|
@ -92,13 +120,7 @@ export const createConversationsService = (
|
||||||
},
|
},
|
||||||
|
|
||||||
createGroup: async (actorId, title, memberUsernames) => {
|
createGroup: async (actorId, title, memberUsernames) => {
|
||||||
const memberIds: string[] = [];
|
const memberIds = await resolveMemberIds(actorId, memberUsernames);
|
||||||
for (const username of memberUsernames) {
|
|
||||||
const found = await users.findByUsername(username);
|
|
||||||
if (found !== undefined && found.id !== actorId) {
|
|
||||||
memberIds.push(found.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const conversation = toConversation(
|
const conversation = toConversation(
|
||||||
await conversations.createGroup(title, actorId, memberIds),
|
await conversations.createGroup(title, actorId, memberIds),
|
||||||
null,
|
null,
|
||||||
|
|
@ -109,6 +131,18 @@ export const createConversationsService = (
|
||||||
return conversation;
|
return conversation;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
createChannel: async (actorId, title, description, memberUsernames) => {
|
||||||
|
const memberIds = await resolveMemberIds(actorId, memberUsernames);
|
||||||
|
const conversation = toConversation(
|
||||||
|
await conversations.createChannel(title, description, actorId, memberIds),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
for (const userId of [actorId, ...memberIds]) {
|
||||||
|
await publish(userChannel(userId), conversationNew(conversation));
|
||||||
|
}
|
||||||
|
return conversation;
|
||||||
|
},
|
||||||
|
|
||||||
list: async (userId) => {
|
list: async (userId) => {
|
||||||
const rows = await conversations.listForUser(userId);
|
const rows = await conversations.listForUser(userId);
|
||||||
const directIds = rows.filter((row) => row.type === 'direct').map((row) => row.id);
|
const directIds = rows.filter((row) => row.type === 'direct').map((row) => row.id);
|
||||||
|
|
@ -116,11 +150,17 @@ export const createConversationsService = (
|
||||||
const unread = await readState.unreadCounts(userId);
|
const unread = await readState.unreadCounts(userId);
|
||||||
return rows.map((row) => {
|
return rows.map((row) => {
|
||||||
const count = unread.get(row.id) ?? 0;
|
const count = unread.get(row.id) ?? 0;
|
||||||
|
const lastMessage = toLastMessagePreview(row);
|
||||||
if (row.type !== 'direct') {
|
if (row.type !== 'direct') {
|
||||||
return toConversation(row, null, count);
|
return toConversation(row, null, count, lastMessage);
|
||||||
}
|
}
|
||||||
const peer = peers.get(row.id);
|
const peer = peers.get(row.id);
|
||||||
return toConversation(row, peer === undefined ? null : memberToPublicUser(peer), count);
|
return toConversation(
|
||||||
|
row,
|
||||||
|
peer === undefined ? null : memberToPublicUser(peer),
|
||||||
|
count,
|
||||||
|
lastMessage,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -180,6 +220,54 @@ export const createConversationsService = (
|
||||||
const event: ReadReceiptEvent = { type: EventType.ReadReceipt, conversationId, userId, seq };
|
const event: ReadReceiptEvent = { type: EventType.ReadReceipt, conversationId, userId, seq };
|
||||||
await deliver(conversationId, event);
|
await deliver(conversationId, event);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Per-user view state: events go to the acting user's personal channel only.
|
||||||
|
clear: async (conversationId, userId) => {
|
||||||
|
await assertMember(conversationId, userId);
|
||||||
|
const upToSeq = await conversations.clearForUser(conversationId, userId);
|
||||||
|
const event: ConversationClearedEvent = {
|
||||||
|
type: EventType.ConversationCleared,
|
||||||
|
conversationId,
|
||||||
|
upToSeq,
|
||||||
|
};
|
||||||
|
await publish(userChannel(userId), event);
|
||||||
|
},
|
||||||
|
|
||||||
|
hide: async (conversationId, userId) => {
|
||||||
|
await assertMember(conversationId, userId);
|
||||||
|
await conversations.hideForUser(conversationId, userId);
|
||||||
|
const event: ConversationHiddenEvent = {
|
||||||
|
type: EventType.ConversationHidden,
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
await publish(userChannel(userId), event);
|
||||||
|
},
|
||||||
|
|
||||||
|
setAvatar: async (conversationId, actorId, imageUrl) => {
|
||||||
|
const existing = await conversations.findById(conversationId);
|
||||||
|
if (existing === undefined) {
|
||||||
|
throw new HttpError(404, 'not_found', 'Conversation not found');
|
||||||
|
}
|
||||||
|
if (existing.type === 'direct') {
|
||||||
|
throw new HttpError(400, 'invalid_target', 'Direct chats have no avatar');
|
||||||
|
}
|
||||||
|
// Only the owner/admins may change a group/channel photo.
|
||||||
|
const role = await conversations.getRole(conversationId, actorId);
|
||||||
|
if (role !== 'owner' && role !== 'admin') {
|
||||||
|
throw new HttpError(403, 'not_permitted', 'Only admins can change the photo');
|
||||||
|
}
|
||||||
|
const row = await conversations.setImage(conversationId, imageUrl);
|
||||||
|
if (row === undefined) {
|
||||||
|
throw new HttpError(404, 'not_found', 'Conversation not found');
|
||||||
|
}
|
||||||
|
const conversation = toConversation(row, null);
|
||||||
|
// Broadcast so every member's list/header picks up the new photo.
|
||||||
|
const info = await conversations.getDeliveryInfo(conversationId);
|
||||||
|
for (const memberId of info?.memberIds ?? []) {
|
||||||
|
await publish(userChannel(memberId), conversationNew(conversation));
|
||||||
|
}
|
||||||
|
return conversation;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
137
packages/backend/src/modules/folders/folders.repository.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import type { Kysely } from 'kysely';
|
||||||
|
import type { ChatFolder, ChatPin, FoldersState } from '@altricade/core';
|
||||||
|
import type { Database } from '../../db/schema';
|
||||||
|
|
||||||
|
export interface FoldersRepository {
|
||||||
|
/** The user's full folders + pins snapshot (the shape every mutation returns). */
|
||||||
|
getState(userId: string): Promise<FoldersState>;
|
||||||
|
create(userId: string, title: string): Promise<void>;
|
||||||
|
/** Returns false when the folder does not belong to the user. */
|
||||||
|
owns(userId: string, folderId: string): Promise<boolean>;
|
||||||
|
rename(folderId: string, title: string): Promise<void>;
|
||||||
|
setPosition(folderId: string, position: number): Promise<void>;
|
||||||
|
setChats(folderId: string, conversationIds: string[]): Promise<void>;
|
||||||
|
remove(folderId: string): Promise<void>;
|
||||||
|
pin(userId: string, conversationId: string, folderId: string | null): Promise<void>;
|
||||||
|
unpin(userId: string, conversationId: string, folderId: string | null): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createFoldersRepository = (db: Kysely<Database>): FoldersRepository => ({
|
||||||
|
getState: async (userId) => {
|
||||||
|
const folders = await db
|
||||||
|
.selectFrom('chat_folders')
|
||||||
|
.select(['id', 'title', 'position'])
|
||||||
|
.where('user_id', '=', userId)
|
||||||
|
.orderBy('position', 'asc')
|
||||||
|
.orderBy('created_at', 'asc')
|
||||||
|
.execute();
|
||||||
|
const items =
|
||||||
|
folders.length === 0
|
||||||
|
? []
|
||||||
|
: await db
|
||||||
|
.selectFrom('chat_folder_items')
|
||||||
|
.select(['folder_id', 'conversation_id'])
|
||||||
|
.where(
|
||||||
|
'folder_id',
|
||||||
|
'in',
|
||||||
|
folders.map((folder) => folder.id),
|
||||||
|
)
|
||||||
|
.orderBy('created_at', 'asc')
|
||||||
|
.execute();
|
||||||
|
const pins = await db
|
||||||
|
.selectFrom('chat_pins')
|
||||||
|
.select(['conversation_id', 'folder_id', 'pinned_at'])
|
||||||
|
.where('user_id', '=', userId)
|
||||||
|
.orderBy('pinned_at', 'desc')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
const folderList: ChatFolder[] = folders.map((folder) => ({
|
||||||
|
id: folder.id,
|
||||||
|
title: folder.title,
|
||||||
|
position: folder.position,
|
||||||
|
chatIds: items
|
||||||
|
.filter((item) => item.folder_id === folder.id)
|
||||||
|
.map((item) => item.conversation_id),
|
||||||
|
}));
|
||||||
|
const pinList: ChatPin[] = pins.map((pin) => ({
|
||||||
|
conversationId: pin.conversation_id,
|
||||||
|
folderId: pin.folder_id,
|
||||||
|
pinnedAt: pin.pinned_at.toISOString(),
|
||||||
|
}));
|
||||||
|
return { folders: folderList, pins: pinList };
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (userId, title) => {
|
||||||
|
// New folders go last.
|
||||||
|
const max = await db
|
||||||
|
.selectFrom('chat_folders')
|
||||||
|
.select((eb) => eb.fn.max('position').as('max_position'))
|
||||||
|
.where('user_id', '=', userId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
await db
|
||||||
|
.insertInto('chat_folders')
|
||||||
|
.values({ user_id: userId, title, position: (max?.max_position ?? -1) + 1 })
|
||||||
|
.execute();
|
||||||
|
},
|
||||||
|
|
||||||
|
owns: async (userId, folderId) => {
|
||||||
|
const row = await db
|
||||||
|
.selectFrom('chat_folders')
|
||||||
|
.select('id')
|
||||||
|
.where('id', '=', folderId)
|
||||||
|
.where('user_id', '=', userId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
return row !== undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
rename: async (folderId, title) => {
|
||||||
|
await db.updateTable('chat_folders').set({ title }).where('id', '=', folderId).execute();
|
||||||
|
},
|
||||||
|
|
||||||
|
setPosition: async (folderId, position) => {
|
||||||
|
await db.updateTable('chat_folders').set({ position }).where('id', '=', folderId).execute();
|
||||||
|
},
|
||||||
|
|
||||||
|
setChats: async (folderId, conversationIds) => {
|
||||||
|
await db.transaction().execute(async (trx) => {
|
||||||
|
await trx.deleteFrom('chat_folder_items').where('folder_id', '=', folderId).execute();
|
||||||
|
if (conversationIds.length > 0) {
|
||||||
|
await trx
|
||||||
|
.insertInto('chat_folder_items')
|
||||||
|
.values(
|
||||||
|
conversationIds.map((conversationId) => ({
|
||||||
|
folder_id: folderId,
|
||||||
|
conversation_id: conversationId,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.onConflict((oc) => oc.columns(['folder_id', 'conversation_id']).doNothing())
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
remove: async (folderId) => {
|
||||||
|
// Items + folder-scoped pins cascade via FK.
|
||||||
|
await db.deleteFrom('chat_folders').where('id', '=', folderId).execute();
|
||||||
|
},
|
||||||
|
|
||||||
|
pin: async (userId, conversationId, folderId) => {
|
||||||
|
await db
|
||||||
|
.insertInto('chat_pins')
|
||||||
|
.values({ user_id: userId, conversation_id: conversationId, folder_id: folderId })
|
||||||
|
.onConflict((oc) => oc.doNothing())
|
||||||
|
.execute();
|
||||||
|
},
|
||||||
|
|
||||||
|
unpin: async (userId, conversationId, folderId) => {
|
||||||
|
let query = db
|
||||||
|
.deleteFrom('chat_pins')
|
||||||
|
.where('user_id', '=', userId)
|
||||||
|
.where('conversation_id', '=', conversationId);
|
||||||
|
query =
|
||||||
|
folderId === null
|
||||||
|
? query.where('folder_id', 'is', null)
|
||||||
|
: query.where('folder_id', '=', folderId);
|
||||||
|
await query.execute();
|
||||||
|
},
|
||||||
|
});
|
||||||
119
packages/backend/src/modules/folders/folders.routes.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import {
|
||||||
|
foldersStateSchema,
|
||||||
|
createFolderBodySchema,
|
||||||
|
updateFolderBodySchema,
|
||||||
|
setPinBodySchema,
|
||||||
|
errorSchema,
|
||||||
|
} from '@altricade/core';
|
||||||
|
import type { CreateFolderBody, UpdateFolderBody, SetPinBody } from '@altricade/core';
|
||||||
|
|
||||||
|
const bearerAuth = [{ bearerAuth: [] }];
|
||||||
|
const folderParamsSchema = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['folderId'],
|
||||||
|
properties: { folderId: { type: 'string', format: 'uuid' } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const foldersRoutes = (app: FastifyInstance): Promise<void> => {
|
||||||
|
app.get(
|
||||||
|
'/folders',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['folders'],
|
||||||
|
summary: 'Get chat folders + pins (full snapshot)',
|
||||||
|
security: bearerAuth,
|
||||||
|
response: { 200: foldersStateSchema, 401: errorSchema },
|
||||||
|
},
|
||||||
|
preHandler: app.authenticate,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const user = request.authUser;
|
||||||
|
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
|
return reply.send(await app.foldersService.get(user.id));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Body: CreateFolderBody }>(
|
||||||
|
'/folders',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['folders'],
|
||||||
|
summary: 'Create a chat folder',
|
||||||
|
security: bearerAuth,
|
||||||
|
body: createFolderBodySchema,
|
||||||
|
response: { 200: foldersStateSchema, 400: errorSchema, 401: errorSchema },
|
||||||
|
},
|
||||||
|
preHandler: app.authenticate,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const user = request.authUser;
|
||||||
|
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
|
return reply.send(await app.foldersService.create(user.id, request.body));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Registered before the :folderId routes is irrelevant for PUT (distinct
|
||||||
|
// method), but keep the static path clearly separate.
|
||||||
|
app.put<{ Body: SetPinBody }>(
|
||||||
|
'/folders/pins',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['folders'],
|
||||||
|
summary: 'Pin/unpin a chat within a scope (folderId null = All chats)',
|
||||||
|
security: bearerAuth,
|
||||||
|
body: setPinBodySchema,
|
||||||
|
response: { 200: foldersStateSchema, 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||||
|
},
|
||||||
|
preHandler: app.authenticate,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const user = request.authUser;
|
||||||
|
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
|
return reply.send(await app.foldersService.setPin(user.id, request.body));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.patch<{ Params: { folderId: string }; Body: UpdateFolderBody }>(
|
||||||
|
'/folders/:folderId',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['folders'],
|
||||||
|
summary: 'Rename / reorder / set the chats of a folder',
|
||||||
|
security: bearerAuth,
|
||||||
|
params: folderParamsSchema,
|
||||||
|
body: updateFolderBodySchema,
|
||||||
|
response: { 200: foldersStateSchema, 401: errorSchema, 404: errorSchema },
|
||||||
|
},
|
||||||
|
preHandler: app.authenticate,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const user = request.authUser;
|
||||||
|
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
|
return reply.send(
|
||||||
|
await app.foldersService.update(user.id, request.params.folderId, request.body),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { folderId: string } }>(
|
||||||
|
'/folders/:folderId',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['folders'],
|
||||||
|
summary: 'Delete a folder (its pins go with it; chats are untouched)',
|
||||||
|
security: bearerAuth,
|
||||||
|
params: folderParamsSchema,
|
||||||
|
response: { 200: foldersStateSchema, 401: errorSchema, 404: errorSchema },
|
||||||
|
},
|
||||||
|
preHandler: app.authenticate,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const user = request.authUser;
|
||||||
|
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
|
return reply.send(await app.foldersService.remove(user.id, request.params.folderId));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
106
packages/backend/src/modules/folders/folders.service.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { EventType, userChannel } from '@altricade/core';
|
||||||
|
import type {
|
||||||
|
FoldersState,
|
||||||
|
FoldersUpdateEvent,
|
||||||
|
CreateFolderBody,
|
||||||
|
UpdateFolderBody,
|
||||||
|
SetPinBody,
|
||||||
|
} from '@altricade/core';
|
||||||
|
import { HttpError } from '../../shared/http-error';
|
||||||
|
import type { Publisher } from '../../shared/publisher';
|
||||||
|
import type { ConversationsRepository } from '../conversations';
|
||||||
|
import type { FoldersRepository } from './folders.repository';
|
||||||
|
|
||||||
|
export interface FoldersServiceDeps {
|
||||||
|
folders: FoldersRepository;
|
||||||
|
conversations: ConversationsRepository;
|
||||||
|
publish: Publisher;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FoldersService {
|
||||||
|
get(userId: string): Promise<FoldersState>;
|
||||||
|
create(userId: string, body: CreateFolderBody): Promise<FoldersState>;
|
||||||
|
update(userId: string, folderId: string, body: UpdateFolderBody): Promise<FoldersState>;
|
||||||
|
remove(userId: string, folderId: string): Promise<FoldersState>;
|
||||||
|
setPin(userId: string, body: SetPinBody): Promise<FoldersState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_FOLDERS = 20;
|
||||||
|
|
||||||
|
export const createFoldersService = (deps: FoldersServiceDeps): FoldersService => {
|
||||||
|
const { folders, conversations, publish } = deps;
|
||||||
|
|
||||||
|
const assertOwnsFolder = async (userId: string, folderId: string): Promise<void> => {
|
||||||
|
if (!(await folders.owns(userId, folderId))) {
|
||||||
|
throw new HttpError(404, 'not_found', 'Folder not found');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Every mutation returns the fresh snapshot AND pushes it to the user's
|
||||||
|
// personal channel so their other devices converge without refetching.
|
||||||
|
const snapshotAndBroadcast = async (userId: string): Promise<FoldersState> => {
|
||||||
|
const state = await folders.getState(userId);
|
||||||
|
const event: FoldersUpdateEvent = { type: EventType.FoldersUpdate, state };
|
||||||
|
await publish(userChannel(userId), event);
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
get: (userId) => folders.getState(userId),
|
||||||
|
|
||||||
|
create: async (userId, body) => {
|
||||||
|
const current = await folders.getState(userId);
|
||||||
|
if (current.folders.length >= MAX_FOLDERS) {
|
||||||
|
throw new HttpError(400, 'too_many_folders', `At most ${String(MAX_FOLDERS)} folders`);
|
||||||
|
}
|
||||||
|
await folders.create(userId, body.title);
|
||||||
|
return snapshotAndBroadcast(userId);
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (userId, folderId, body) => {
|
||||||
|
await assertOwnsFolder(userId, folderId);
|
||||||
|
if (body.title !== undefined) {
|
||||||
|
await folders.rename(folderId, body.title);
|
||||||
|
}
|
||||||
|
if (body.position !== undefined) {
|
||||||
|
await folders.setPosition(folderId, body.position);
|
||||||
|
}
|
||||||
|
if (body.chatIds !== undefined) {
|
||||||
|
// Only chats the user is actually in can enter a folder.
|
||||||
|
const memberships = await Promise.all(
|
||||||
|
body.chatIds.map((chatId) => conversations.isMember(chatId, userId)),
|
||||||
|
);
|
||||||
|
const allowed = body.chatIds.filter((_, index) => memberships[index] === true);
|
||||||
|
await folders.setChats(folderId, allowed);
|
||||||
|
}
|
||||||
|
return snapshotAndBroadcast(userId);
|
||||||
|
},
|
||||||
|
|
||||||
|
remove: async (userId, folderId) => {
|
||||||
|
await assertOwnsFolder(userId, folderId);
|
||||||
|
await folders.remove(folderId);
|
||||||
|
return snapshotAndBroadcast(userId);
|
||||||
|
},
|
||||||
|
|
||||||
|
setPin: async (userId, body) => {
|
||||||
|
if (!(await conversations.isMember(body.conversationId, userId))) {
|
||||||
|
throw new HttpError(403, 'not_a_member', 'You are not a member of this conversation');
|
||||||
|
}
|
||||||
|
if (body.folderId !== null) {
|
||||||
|
await assertOwnsFolder(userId, body.folderId);
|
||||||
|
}
|
||||||
|
if (body.pinned) {
|
||||||
|
await folders.pin(userId, body.conversationId, body.folderId);
|
||||||
|
} else {
|
||||||
|
await folders.unpin(userId, body.conversationId, body.folderId);
|
||||||
|
}
|
||||||
|
return snapshotAndBroadcast(userId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyInstance {
|
||||||
|
foldersService: FoldersService;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
packages/backend/src/modules/folders/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export { createFoldersRepository } from './folders.repository';
|
||||||
|
export type { FoldersRepository } from './folders.repository';
|
||||||
|
export { createFoldersService } from './folders.service';
|
||||||
|
export type { FoldersService, FoldersServiceDeps } from './folders.service';
|
||||||
|
export { foldersRoutes } from './folders.routes';
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
errorSchema,
|
errorSchema,
|
||||||
} from '@altricade/core';
|
} from '@altricade/core';
|
||||||
import type { UploadUrlBody, AvatarUploadBody } from '@altricade/core';
|
import type { UploadUrlBody, AvatarUploadBody } from '@altricade/core';
|
||||||
|
import { requestOrigin } from '../../shared/request-origin';
|
||||||
|
|
||||||
const bearerAuth = [{ bearerAuth: [] }];
|
const bearerAuth = [{ bearerAuth: [] }];
|
||||||
|
|
||||||
|
|
@ -31,6 +32,7 @@ export const mediaRoutes = (app: FastifyInstance): Promise<void> => {
|
||||||
request.body.kind,
|
request.body.kind,
|
||||||
request.body.mime,
|
request.body.mime,
|
||||||
request.body.size,
|
request.body.size,
|
||||||
|
requestOrigin(request),
|
||||||
);
|
);
|
||||||
return reply.send(target);
|
return reply.send(target);
|
||||||
},
|
},
|
||||||
|
|
@ -51,7 +53,11 @@ export const mediaRoutes = (app: FastifyInstance): Promise<void> => {
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const user = request.authUser;
|
const user = request.authUser;
|
||||||
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
const target = await app.mediaService.createAvatarUploadUrl(user.id, request.body.mime);
|
const target = await app.mediaService.createAvatarUploadUrl(
|
||||||
|
user.id,
|
||||||
|
request.body.mime,
|
||||||
|
requestOrigin(request),
|
||||||
|
);
|
||||||
return reply.send(target);
|
return reply.send(target);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import type { MediaKind } from '@altricade/core';
|
||||||
import { HttpError } from '../../shared/http-error';
|
import { HttpError } from '../../shared/http-error';
|
||||||
|
|
||||||
export interface MediaServiceDeps {
|
export interface MediaServiceDeps {
|
||||||
// The presigning (browser-facing) MinIO client.
|
// Presigning client for a given public origin (null → configured public URL).
|
||||||
minio: Client;
|
presignClient: (origin: string | null) => Client;
|
||||||
mediaBucket: string;
|
mediaBucket: string;
|
||||||
avatarsBucket: string;
|
avatarsBucket: string;
|
||||||
publicUrl: string;
|
publicUrl: string;
|
||||||
|
|
@ -20,10 +20,19 @@ export interface AvatarTarget extends UploadTarget {
|
||||||
publicUrl: string;
|
publicUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `origin` is the public origin the request arrived on — presigned URLs are
|
||||||
|
// signed for it so they stay reachable from that same client (a browser on
|
||||||
|
// localhost and a phone on a LAN IP get different, individually valid URLs).
|
||||||
export interface MediaService {
|
export interface MediaService {
|
||||||
createUploadUrl(userId: string, kind: MediaKind, mime: string, size: number): Promise<UploadTarget>;
|
createUploadUrl(
|
||||||
createAvatarUploadUrl(userId: string, mime: string): Promise<AvatarTarget>;
|
userId: string,
|
||||||
downloadUrl(objectKey: string): Promise<string>;
|
kind: MediaKind,
|
||||||
|
mime: string,
|
||||||
|
size: number,
|
||||||
|
origin: string | null,
|
||||||
|
): Promise<UploadTarget>;
|
||||||
|
createAvatarUploadUrl(userId: string, mime: string, origin: string | null): Promise<AvatarTarget>;
|
||||||
|
downloadUrl(objectKey: string, origin: string | null): Promise<string>;
|
||||||
avatarPublicUrl(objectKey: string): string;
|
avatarPublicUrl(objectKey: string): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,31 +41,31 @@ const DOWNLOAD_EXPIRY = 3600;
|
||||||
|
|
||||||
const kindMatches = (kind: MediaKind, mime: string): boolean => {
|
const kindMatches = (kind: MediaKind, mime: string): boolean => {
|
||||||
if (kind === 'image') return mime.startsWith('image/');
|
if (kind === 'image') return mime.startsWith('image/');
|
||||||
if (kind === 'video') return mime.startsWith('video/');
|
if (kind === 'video' || kind === 'video_note') return mime.startsWith('video/');
|
||||||
if (kind === 'voice') return mime.startsWith('audio/');
|
if (kind === 'voice') return mime.startsWith('audio/');
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createMediaService = (deps: MediaServiceDeps): MediaService => ({
|
export const createMediaService = (deps: MediaServiceDeps): MediaService => ({
|
||||||
createUploadUrl: async (userId, kind, mime, _size) => {
|
createUploadUrl: async (userId, kind, mime, _size, origin) => {
|
||||||
if (!kindMatches(kind, mime)) {
|
if (!kindMatches(kind, mime)) {
|
||||||
throw new HttpError(400, 'invalid_media', `Content type ${mime} does not match kind ${kind}`);
|
throw new HttpError(400, 'invalid_media', `Content type ${mime} does not match kind ${kind}`);
|
||||||
}
|
}
|
||||||
const objectKey = `${userId}/${randomUUID()}`;
|
const objectKey = `${userId}/${randomUUID()}`;
|
||||||
const uploadUrl = await deps.minio.presignedPutObject(deps.mediaBucket, objectKey, UPLOAD_EXPIRY);
|
const uploadUrl = await deps
|
||||||
|
.presignClient(origin)
|
||||||
|
.presignedPutObject(deps.mediaBucket, objectKey, UPLOAD_EXPIRY);
|
||||||
return { uploadUrl, objectKey };
|
return { uploadUrl, objectKey };
|
||||||
},
|
},
|
||||||
|
|
||||||
createAvatarUploadUrl: async (userId, mime) => {
|
createAvatarUploadUrl: async (userId, mime, origin) => {
|
||||||
if (!mime.startsWith('image/')) {
|
if (!mime.startsWith('image/')) {
|
||||||
throw new HttpError(400, 'invalid_media', 'Avatar must be an image');
|
throw new HttpError(400, 'invalid_media', 'Avatar must be an image');
|
||||||
}
|
}
|
||||||
const objectKey = `${userId}/${randomUUID()}`;
|
const objectKey = `${userId}/${randomUUID()}`;
|
||||||
const uploadUrl = await deps.minio.presignedPutObject(
|
const uploadUrl = await deps
|
||||||
deps.avatarsBucket,
|
.presignClient(origin)
|
||||||
objectKey,
|
.presignedPutObject(deps.avatarsBucket, objectKey, UPLOAD_EXPIRY);
|
||||||
UPLOAD_EXPIRY,
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
uploadUrl,
|
uploadUrl,
|
||||||
objectKey,
|
objectKey,
|
||||||
|
|
@ -64,9 +73,11 @@ export const createMediaService = (deps: MediaServiceDeps): MediaService => ({
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
downloadUrl: (objectKey) =>
|
downloadUrl: (objectKey, origin) =>
|
||||||
deps.minio.presignedGetObject(deps.mediaBucket, objectKey, DOWNLOAD_EXPIRY),
|
deps.presignClient(origin).presignedGetObject(deps.mediaBucket, objectKey, DOWNLOAD_EXPIRY),
|
||||||
|
|
||||||
|
// Avatars are persisted (users.avatar_ref), so they use the one stable
|
||||||
|
// configured public URL rather than a per-request origin.
|
||||||
avatarPublicUrl: (objectKey) => `${deps.publicUrl}/${deps.avatarsBucket}/${objectKey}`,
|
avatarPublicUrl: (objectKey) => `${deps.publicUrl}/${deps.avatarsBucket}/${objectKey}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,24 @@
|
||||||
import type { Message, ReactionSummary } from '@altricade/core';
|
import type { Message, ReactionSummary, ReplyPreview, MediaRef } from '@altricade/core';
|
||||||
import type { MessageWithSenderRow } from './messages.repository';
|
import type { MessageWithSenderRow } from './messages.repository';
|
||||||
|
|
||||||
|
const MEDIA_KINDS: readonly MediaRef['kind'][] = ['image', 'video', 'video_note', 'voice', 'file'];
|
||||||
|
|
||||||
|
const toReplyPreview = (row: MessageWithSenderRow): ReplyPreview | null => {
|
||||||
|
if (row.reply_to_id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const deleted = row.reply_deleted_at !== null;
|
||||||
|
const rawKind = row.reply_media_kind;
|
||||||
|
const mediaKind = MEDIA_KINDS.find((kind) => kind === rawKind) ?? null;
|
||||||
|
return {
|
||||||
|
id: row.reply_to_id,
|
||||||
|
senderName: row.reply_sender_name ?? '',
|
||||||
|
content: deleted ? '' : (row.reply_content ?? ''),
|
||||||
|
mediaKind: deleted ? null : mediaKind,
|
||||||
|
deleted,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const toMessage = (
|
export const toMessage = (
|
||||||
row: MessageWithSenderRow,
|
row: MessageWithSenderRow,
|
||||||
reactions: ReactionSummary[] = [],
|
reactions: ReactionSummary[] = [],
|
||||||
|
|
@ -24,4 +42,7 @@ export const toMessage = (
|
||||||
deletedAt: row.deleted_at === null ? null : row.deleted_at.toISOString(),
|
deletedAt: row.deleted_at === null ? null : row.deleted_at.toISOString(),
|
||||||
reactions,
|
reactions,
|
||||||
media: row.media_meta,
|
media: row.media_meta,
|
||||||
|
replyTo: toReplyPreview(row),
|
||||||
|
forwarded: row.forwarded,
|
||||||
|
forwardedFrom: row.forwarded_from,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { sql } from 'kysely';
|
import { sql } from 'kysely';
|
||||||
import type { Kysely } from 'kysely';
|
import type { Kysely } from 'kysely';
|
||||||
import type { ReactionSummary, MediaRef } from '@altricade/core';
|
import type { ReactionSummary, MediaRef, ForwardOrigin } from '@altricade/core';
|
||||||
import type { Database } from '../../db/schema';
|
import type { Database } from '../../db/schema';
|
||||||
|
|
||||||
export interface MessageWithSenderRow {
|
export interface MessageWithSenderRow {
|
||||||
|
|
@ -20,6 +20,13 @@ export interface MessageWithSenderRow {
|
||||||
sender_username: string;
|
sender_username: string;
|
||||||
sender_display_name: string;
|
sender_display_name: string;
|
||||||
sender_avatar_ref: string | null;
|
sender_avatar_ref: string | null;
|
||||||
|
reply_to_id: string | null;
|
||||||
|
reply_content: string | null;
|
||||||
|
reply_deleted_at: Date | null;
|
||||||
|
reply_media_kind: string | null;
|
||||||
|
reply_sender_name: string | null;
|
||||||
|
forwarded: boolean;
|
||||||
|
forwarded_from: ForwardOrigin | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NewMessage {
|
export interface NewMessage {
|
||||||
|
|
@ -31,6 +38,9 @@ export interface NewMessage {
|
||||||
encryption: string | null;
|
encryption: string | null;
|
||||||
mediaKey: string | null;
|
mediaKey: string | null;
|
||||||
media: MediaRef | null;
|
media: MediaRef | null;
|
||||||
|
replyToId: string | null;
|
||||||
|
forwarded: boolean;
|
||||||
|
forwardedFrom: ForwardOrigin | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessagesRepository {
|
export interface MessagesRepository {
|
||||||
|
|
@ -41,8 +51,32 @@ export interface MessagesRepository {
|
||||||
clientMsgId: string,
|
clientMsgId: string,
|
||||||
): Promise<string | undefined>;
|
): Promise<string | undefined>;
|
||||||
getWithSenderById(id: string): Promise<MessageWithSenderRow | undefined>;
|
getWithSenderById(id: string): Promise<MessageWithSenderRow | undefined>;
|
||||||
|
/** History as seen by `userId`: excludes their hidden messages and anything at or below their clear mark. */
|
||||||
listHistory(
|
listHistory(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
|
userId: string,
|
||||||
|
beforeSeq: number | null,
|
||||||
|
limit: number,
|
||||||
|
): Promise<MessageWithSenderRow[]>;
|
||||||
|
/** Ascending page after `afterSeq` (downward pagination from a jumped-to context). */
|
||||||
|
listAfter(
|
||||||
|
conversationId: string,
|
||||||
|
userId: string,
|
||||||
|
afterSeq: number,
|
||||||
|
limit: number,
|
||||||
|
): Promise<MessageWithSenderRow[]>;
|
||||||
|
/** A window centered on `seq`: `half` rows above and `half` rows from it downward. */
|
||||||
|
listContext(
|
||||||
|
conversationId: string,
|
||||||
|
userId: string,
|
||||||
|
seq: number,
|
||||||
|
half: number,
|
||||||
|
): Promise<MessageWithSenderRow[]>;
|
||||||
|
/** Media messages as seen by `userId` (profile panel tabs), newest first. */
|
||||||
|
listMedia(
|
||||||
|
conversationId: string,
|
||||||
|
userId: string,
|
||||||
|
kinds: string[],
|
||||||
beforeSeq: number | null,
|
beforeSeq: number | null,
|
||||||
limit: number,
|
limit: number,
|
||||||
): Promise<MessageWithSenderRow[]>;
|
): Promise<MessageWithSenderRow[]>;
|
||||||
|
|
@ -53,9 +87,16 @@ export interface MessagesRepository {
|
||||||
content: string,
|
content: string,
|
||||||
): Promise<{ id: string } | undefined>;
|
): Promise<{ id: string } | undefined>;
|
||||||
softDelete(messageId: string, senderId: string): Promise<{ id: string } | undefined>;
|
softDelete(messageId: string, senderId: string): Promise<{ id: string } | undefined>;
|
||||||
|
/** Moderation variant: no sender check (permission enforced in the service). */
|
||||||
|
softDeleteAny(messageId: string): Promise<{ id: string } | undefined>;
|
||||||
|
/** "Delete for me": per-user tombstone; idempotent. */
|
||||||
|
hide(messageId: string, userId: string): Promise<void>;
|
||||||
addReaction(messageId: string, userId: string, emoji: string): Promise<void>;
|
addReaction(messageId: string, userId: string, emoji: string): Promise<void>;
|
||||||
removeReaction(messageId: string, userId: string, emoji: string): Promise<void>;
|
removeReaction(messageId: string, userId: string, emoji: string): Promise<void>;
|
||||||
reactionsFor(messageIds: string[], userId: string): Promise<Map<string, ReactionSummary[]>>;
|
reactionsFor(messageIds: string[], userId: string): Promise<Map<string, ReactionSummary[]>>;
|
||||||
|
pin(conversationId: string, messageId: string, pinnedBy: string): Promise<void>;
|
||||||
|
unpin(conversationId: string, messageId: string): Promise<void>;
|
||||||
|
listPinned(conversationId: string): Promise<MessageWithSenderRow[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createMessagesRepository = (db: Kysely<Database>): MessagesRepository => {
|
export const createMessagesRepository = (db: Kysely<Database>): MessagesRepository => {
|
||||||
|
|
@ -63,7 +104,10 @@ export const createMessagesRepository = (db: Kysely<Database>): MessagesReposito
|
||||||
db
|
db
|
||||||
.selectFrom('messages')
|
.selectFrom('messages')
|
||||||
.innerJoin('users', 'users.id', 'messages.sender_id')
|
.innerJoin('users', 'users.id', 'messages.sender_id')
|
||||||
.select([
|
// Reply preview: the replied message + its author (both optional).
|
||||||
|
.leftJoin('messages as reply', 'reply.id', 'messages.reply_to_id')
|
||||||
|
.leftJoin('users as reply_user', 'reply_user.id', 'reply.sender_id')
|
||||||
|
.select((eb) => [
|
||||||
'messages.id as id',
|
'messages.id as id',
|
||||||
'messages.seq as seq',
|
'messages.seq as seq',
|
||||||
'messages.conversation_id as conversation_id',
|
'messages.conversation_id as conversation_id',
|
||||||
|
|
@ -80,8 +124,37 @@ export const createMessagesRepository = (db: Kysely<Database>): MessagesReposito
|
||||||
'users.username as sender_username',
|
'users.username as sender_username',
|
||||||
'users.display_name as sender_display_name',
|
'users.display_name as sender_display_name',
|
||||||
'users.avatar_ref as sender_avatar_ref',
|
'users.avatar_ref as sender_avatar_ref',
|
||||||
|
'messages.reply_to_id as reply_to_id',
|
||||||
|
'reply.content as reply_content',
|
||||||
|
'reply.deleted_at as reply_deleted_at',
|
||||||
|
eb.fn<string | null>('nullif', [sql`reply.media_meta->>'kind'`, sql`''`]).as('reply_media_kind'),
|
||||||
|
'reply_user.display_name as reply_sender_name',
|
||||||
|
'messages.forwarded as forwarded',
|
||||||
|
'messages.forwarded_from as forwarded_from',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// The per-user view: joins the member row to honor the clear mark and
|
||||||
|
// anti-joins the "delete for me" tombstones.
|
||||||
|
const visibleTo = (userId: string) =>
|
||||||
|
withSender()
|
||||||
|
.innerJoin('conversation_members', (join) =>
|
||||||
|
join
|
||||||
|
.onRef('conversation_members.conversation_id', '=', 'messages.conversation_id')
|
||||||
|
.on('conversation_members.user_id', '=', userId),
|
||||||
|
)
|
||||||
|
.whereRef('messages.seq', '>', 'conversation_members.cleared_up_to_seq')
|
||||||
|
.where((eb) =>
|
||||||
|
eb.not(
|
||||||
|
eb.exists(
|
||||||
|
eb
|
||||||
|
.selectFrom('message_hidden')
|
||||||
|
.select('message_hidden.message_id')
|
||||||
|
.whereRef('message_hidden.message_id', '=', 'messages.id')
|
||||||
|
.where('message_hidden.user_id', '=', userId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
insert: (input) =>
|
insert: (input) =>
|
||||||
db
|
db
|
||||||
|
|
@ -95,6 +168,9 @@ export const createMessagesRepository = (db: Kysely<Database>): MessagesReposito
|
||||||
encryption: input.encryption,
|
encryption: input.encryption,
|
||||||
media_key: input.mediaKey,
|
media_key: input.mediaKey,
|
||||||
media_meta: input.media === null ? null : JSON.stringify(input.media),
|
media_meta: input.media === null ? null : JSON.stringify(input.media),
|
||||||
|
reply_to_id: input.replyToId,
|
||||||
|
forwarded: input.forwarded,
|
||||||
|
forwarded_from: input.forwardedFrom === null ? null : JSON.stringify(input.forwardedFrom),
|
||||||
})
|
})
|
||||||
.onConflict((oc) =>
|
.onConflict((oc) =>
|
||||||
oc.columns(['conversation_id', 'sender_id', 'client_msg_id']).doNothing(),
|
oc.columns(['conversation_id', 'sender_id', 'client_msg_id']).doNothing(),
|
||||||
|
|
@ -115,8 +191,44 @@ export const createMessagesRepository = (db: Kysely<Database>): MessagesReposito
|
||||||
|
|
||||||
getWithSenderById: (id) => withSender().where('messages.id', '=', id).executeTakeFirst(),
|
getWithSenderById: (id) => withSender().where('messages.id', '=', id).executeTakeFirst(),
|
||||||
|
|
||||||
listHistory: (conversationId, beforeSeq, limit) => {
|
listHistory: (conversationId, userId, beforeSeq, limit) => {
|
||||||
let query = withSender().where('messages.conversation_id', '=', conversationId);
|
let query = visibleTo(userId).where('messages.conversation_id', '=', conversationId);
|
||||||
|
if (beforeSeq !== null) {
|
||||||
|
query = query.where('messages.seq', '<', beforeSeq);
|
||||||
|
}
|
||||||
|
return query.orderBy('messages.seq', 'desc').limit(limit).execute();
|
||||||
|
},
|
||||||
|
|
||||||
|
listAfter: (conversationId, userId, afterSeq, limit) =>
|
||||||
|
visibleTo(userId)
|
||||||
|
.where('messages.conversation_id', '=', conversationId)
|
||||||
|
.where('messages.seq', '>', afterSeq)
|
||||||
|
.orderBy('messages.seq', 'asc')
|
||||||
|
.limit(limit)
|
||||||
|
.execute(),
|
||||||
|
|
||||||
|
listContext: async (conversationId, userId, seq, half) => {
|
||||||
|
const above = await visibleTo(userId)
|
||||||
|
.where('messages.conversation_id', '=', conversationId)
|
||||||
|
.where('messages.seq', '<', seq)
|
||||||
|
.orderBy('messages.seq', 'desc')
|
||||||
|
.limit(half)
|
||||||
|
.execute();
|
||||||
|
const fromTarget = await visibleTo(userId)
|
||||||
|
.where('messages.conversation_id', '=', conversationId)
|
||||||
|
.where('messages.seq', '>=', seq)
|
||||||
|
.orderBy('messages.seq', 'asc')
|
||||||
|
.limit(half)
|
||||||
|
.execute();
|
||||||
|
return [...above, ...fromTarget];
|
||||||
|
},
|
||||||
|
|
||||||
|
listMedia: (conversationId, userId, kinds, beforeSeq, limit) => {
|
||||||
|
let query = visibleTo(userId)
|
||||||
|
.where('messages.conversation_id', '=', conversationId)
|
||||||
|
.where('messages.deleted_at', 'is', null)
|
||||||
|
.where('messages.media_key', 'is not', null)
|
||||||
|
.where((eb) => eb(sql<string>`messages.media_meta->>'kind'`, 'in', kinds));
|
||||||
if (beforeSeq !== null) {
|
if (beforeSeq !== null) {
|
||||||
query = query.where('messages.seq', '<', beforeSeq);
|
query = query.where('messages.seq', '<', beforeSeq);
|
||||||
}
|
}
|
||||||
|
|
@ -152,6 +264,23 @@ export const createMessagesRepository = (db: Kysely<Database>): MessagesReposito
|
||||||
.returning('id')
|
.returning('id')
|
||||||
.executeTakeFirst(),
|
.executeTakeFirst(),
|
||||||
|
|
||||||
|
softDeleteAny: (messageId) =>
|
||||||
|
db
|
||||||
|
.updateTable('messages')
|
||||||
|
.set({ deleted_at: new Date(), content: '' })
|
||||||
|
.where('id', '=', messageId)
|
||||||
|
.where('deleted_at', 'is', null)
|
||||||
|
.returning('id')
|
||||||
|
.executeTakeFirst(),
|
||||||
|
|
||||||
|
hide: async (messageId, userId) => {
|
||||||
|
await db
|
||||||
|
.insertInto('message_hidden')
|
||||||
|
.values({ user_id: userId, message_id: messageId })
|
||||||
|
.onConflict((oc) => oc.columns(['user_id', 'message_id']).doNothing())
|
||||||
|
.execute();
|
||||||
|
},
|
||||||
|
|
||||||
addReaction: async (messageId, userId, emoji) => {
|
addReaction: async (messageId, userId, emoji) => {
|
||||||
await db
|
await db
|
||||||
.insertInto('reactions')
|
.insertInto('reactions')
|
||||||
|
|
@ -192,5 +321,29 @@ export const createMessagesRepository = (db: Kysely<Database>): MessagesReposito
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
pin: async (conversationId, messageId, pinnedBy) => {
|
||||||
|
await db
|
||||||
|
.insertInto('message_pins')
|
||||||
|
.values({ conversation_id: conversationId, message_id: messageId, pinned_by: pinnedBy })
|
||||||
|
.onConflict((oc) => oc.columns(['conversation_id', 'message_id']).doNothing())
|
||||||
|
.execute();
|
||||||
|
},
|
||||||
|
|
||||||
|
unpin: async (conversationId, messageId) => {
|
||||||
|
await db
|
||||||
|
.deleteFrom('message_pins')
|
||||||
|
.where('conversation_id', '=', conversationId)
|
||||||
|
.where('message_id', '=', messageId)
|
||||||
|
.execute();
|
||||||
|
},
|
||||||
|
|
||||||
|
listPinned: (conversationId) =>
|
||||||
|
withSender()
|
||||||
|
.innerJoin('message_pins', 'message_pins.message_id', 'messages.id')
|
||||||
|
.where('message_pins.conversation_id', '=', conversationId)
|
||||||
|
.where('messages.deleted_at', 'is', null)
|
||||||
|
.orderBy('message_pins.pinned_at', 'desc')
|
||||||
|
.execute(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,21 @@ import {
|
||||||
sendMessageBodySchema,
|
sendMessageBodySchema,
|
||||||
editMessageBodySchema,
|
editMessageBodySchema,
|
||||||
reactionBodySchema,
|
reactionBodySchema,
|
||||||
|
forwardMessageBodySchema,
|
||||||
messageSchema,
|
messageSchema,
|
||||||
messageListSchema,
|
messageListSchema,
|
||||||
mediaUrlSchema,
|
mediaUrlSchema,
|
||||||
|
mediaListQuerySchema,
|
||||||
errorSchema,
|
errorSchema,
|
||||||
} from '@altricade/core';
|
} from '@altricade/core';
|
||||||
import type { SendMessageBody, EditMessageBody, ReactionBody } from '@altricade/core';
|
import type {
|
||||||
|
SendMessageBody,
|
||||||
|
EditMessageBody,
|
||||||
|
ReactionBody,
|
||||||
|
ForwardMessageBody,
|
||||||
|
MediaTab,
|
||||||
|
} from '@altricade/core';
|
||||||
|
import { requestOrigin } from '../../shared/request-origin';
|
||||||
|
|
||||||
const bearerAuth = [{ bearerAuth: [] }];
|
const bearerAuth = [{ bearerAuth: [] }];
|
||||||
const idParamsSchema = {
|
const idParamsSchema = {
|
||||||
|
|
@ -37,17 +46,26 @@ const historyQuerySchema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
before: { type: 'integer', minimum: 1 },
|
before: { type: 'integer', minimum: 1 },
|
||||||
|
// Ascending page after this seq (downward pagination from a jumped context).
|
||||||
|
after: { type: 'integer', minimum: 0 },
|
||||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 50 },
|
limit: { type: 'integer', minimum: 1, maximum: 100, default: 50 },
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const contextQuerySchema = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
limit: { type: 'integer', minimum: 2, maximum: 100, default: 50 },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const messagesRoutes = (app: FastifyInstance): Promise<void> => {
|
export const messagesRoutes = (app: FastifyInstance): Promise<void> => {
|
||||||
app.get<{ Params: { id: string }; Querystring: { before?: number; limit: number } }>(
|
app.get<{ Params: { id: string }; Querystring: { before?: number; after?: number; limit: number } }>(
|
||||||
'/conversations/:id/messages',
|
'/conversations/:id/messages',
|
||||||
{
|
{
|
||||||
schema: {
|
schema: {
|
||||||
tags: ['messages'],
|
tags: ['messages'],
|
||||||
summary: 'Load conversation message history (newest first)',
|
summary: 'Load conversation message history (newest first; `after` pages downward)',
|
||||||
security: bearerAuth,
|
security: bearerAuth,
|
||||||
params: idParamsSchema,
|
params: idParamsSchema,
|
||||||
querystring: historyQuerySchema,
|
querystring: historyQuerySchema,
|
||||||
|
|
@ -58,6 +76,16 @@ export const messagesRoutes = (app: FastifyInstance): Promise<void> => {
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const user = request.authUser;
|
const user = request.authUser;
|
||||||
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
|
if (request.query.after !== undefined) {
|
||||||
|
return reply.send(
|
||||||
|
await app.messagesService.historyAfter(
|
||||||
|
request.params.id,
|
||||||
|
user.id,
|
||||||
|
request.query.after,
|
||||||
|
request.query.limit,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
const before = request.query.before ?? null;
|
const before = request.query.before ?? null;
|
||||||
const history = await app.messagesService.history(
|
const history = await app.messagesService.history(
|
||||||
request.params.id,
|
request.params.id,
|
||||||
|
|
@ -69,6 +97,33 @@ export const messagesRoutes = (app: FastifyInstance): Promise<void> => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string; messageId: string }; Querystring: { limit: number } }>(
|
||||||
|
'/conversations/:id/messages/:messageId/context',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['messages'],
|
||||||
|
summary: 'Load a history window centered on a message (jump-to-message / search)',
|
||||||
|
security: bearerAuth,
|
||||||
|
params: messageParamsSchema,
|
||||||
|
querystring: contextQuerySchema,
|
||||||
|
response: { 200: messageListSchema, 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||||
|
},
|
||||||
|
preHandler: app.authenticate,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const user = request.authUser;
|
||||||
|
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
|
return reply.send(
|
||||||
|
await app.messagesService.context(
|
||||||
|
request.params.id,
|
||||||
|
user.id,
|
||||||
|
request.params.messageId,
|
||||||
|
request.query.limit,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.post<{ Params: { id: string }; Body: SendMessageBody }>(
|
app.post<{ Params: { id: string }; Body: SendMessageBody }>(
|
||||||
'/conversations/:id/messages',
|
'/conversations/:id/messages',
|
||||||
{
|
{
|
||||||
|
|
@ -136,6 +191,139 @@ export const messagesRoutes = (app: FastifyInstance): Promise<void> => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string; messageId: string } }>(
|
||||||
|
'/conversations/:id/messages/:messageId/hide',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['messages'],
|
||||||
|
summary: 'Delete a message for me only (per-user hide)',
|
||||||
|
security: bearerAuth,
|
||||||
|
params: messageParamsSchema,
|
||||||
|
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||||
|
},
|
||||||
|
preHandler: app.authenticate,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const user = request.authUser;
|
||||||
|
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
|
await app.messagesService.hide(request.params.id, request.params.messageId, user.id);
|
||||||
|
return reply.code(204).send();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Body: ForwardMessageBody }>(
|
||||||
|
'/conversations/:id/forward',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['messages'],
|
||||||
|
summary: 'Forward a message into this conversation',
|
||||||
|
security: bearerAuth,
|
||||||
|
params: idParamsSchema,
|
||||||
|
body: forwardMessageBodySchema,
|
||||||
|
response: { 200: messageSchema, 400: errorSchema, 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||||
|
},
|
||||||
|
preHandler: app.authenticate,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const user = request.authUser;
|
||||||
|
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
|
const message = await app.messagesService.forward(
|
||||||
|
request.params.id,
|
||||||
|
user.id,
|
||||||
|
request.body.sourceConversationId,
|
||||||
|
request.body.messageId,
|
||||||
|
request.body.hideSender ?? false,
|
||||||
|
);
|
||||||
|
return reply.send(message);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>(
|
||||||
|
'/conversations/:id/pinned',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['messages'],
|
||||||
|
summary: 'List pinned messages (newest pin first)',
|
||||||
|
security: bearerAuth,
|
||||||
|
params: idParamsSchema,
|
||||||
|
response: { 200: messageListSchema, 401: errorSchema, 403: errorSchema },
|
||||||
|
},
|
||||||
|
preHandler: app.authenticate,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const user = request.authUser;
|
||||||
|
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
|
return reply.send(await app.messagesService.pinned(request.params.id, user.id));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string; messageId: string } }>(
|
||||||
|
'/conversations/:id/messages/:messageId/pin',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['messages'],
|
||||||
|
summary: 'Pin a message',
|
||||||
|
security: bearerAuth,
|
||||||
|
params: messageParamsSchema,
|
||||||
|
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||||
|
},
|
||||||
|
preHandler: app.authenticate,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const user = request.authUser;
|
||||||
|
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
|
await app.messagesService.pin(request.params.id, request.params.messageId, user.id);
|
||||||
|
return reply.code(204).send();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string; messageId: string } }>(
|
||||||
|
'/conversations/:id/messages/:messageId/pin',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['messages'],
|
||||||
|
summary: 'Unpin a message',
|
||||||
|
security: bearerAuth,
|
||||||
|
params: messageParamsSchema,
|
||||||
|
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||||
|
},
|
||||||
|
preHandler: app.authenticate,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const user = request.authUser;
|
||||||
|
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
|
await app.messagesService.unpin(request.params.id, request.params.messageId, user.id);
|
||||||
|
return reply.code(204).send();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string }; Querystring: { tab: MediaTab; before?: number; limit: number } }>(
|
||||||
|
'/conversations/:id/media',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['messages'],
|
||||||
|
summary: 'List shared media messages (profile panel tabs), newest first',
|
||||||
|
security: bearerAuth,
|
||||||
|
params: idParamsSchema,
|
||||||
|
querystring: mediaListQuerySchema,
|
||||||
|
response: { 200: messageListSchema, 401: errorSchema, 403: errorSchema },
|
||||||
|
},
|
||||||
|
preHandler: app.authenticate,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const user = request.authUser;
|
||||||
|
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
|
const items = await app.messagesService.media(
|
||||||
|
request.params.id,
|
||||||
|
user.id,
|
||||||
|
request.query.tab,
|
||||||
|
request.query.before ?? null,
|
||||||
|
request.query.limit,
|
||||||
|
);
|
||||||
|
return reply.send(items);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.post<{ Params: { id: string; messageId: string }; Body: ReactionBody }>(
|
app.post<{ Params: { id: string; messageId: string }; Body: ReactionBody }>(
|
||||||
'/conversations/:id/messages/:messageId/reactions',
|
'/conversations/:id/messages/:messageId/reactions',
|
||||||
{
|
{
|
||||||
|
|
@ -177,7 +365,12 @@ export const messagesRoutes = (app: FastifyInstance): Promise<void> => {
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const user = request.authUser;
|
const user = request.authUser;
|
||||||
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
const url = await app.messagesService.mediaUrl(request.params.id, request.params.messageId, user.id);
|
const url = await app.messagesService.mediaUrl(
|
||||||
|
request.params.id,
|
||||||
|
request.params.messageId,
|
||||||
|
user.id,
|
||||||
|
requestOrigin(request),
|
||||||
|
);
|
||||||
return reply.send({ url });
|
return reply.send({ url });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
import { EventType } from '@altricade/core';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { EventType, userChannel } from '@altricade/core';
|
||||||
import type {
|
import type {
|
||||||
Message,
|
Message,
|
||||||
MessageNewEvent,
|
MessageNewEvent,
|
||||||
MessageEditEvent,
|
MessageEditEvent,
|
||||||
MessageDeleteEvent,
|
MessageDeleteEvent,
|
||||||
|
MessageHiddenEvent,
|
||||||
|
MessagePinEvent,
|
||||||
ReactionEvent,
|
ReactionEvent,
|
||||||
SendMessageBody,
|
SendMessageBody,
|
||||||
|
MediaTab,
|
||||||
|
ForwardOrigin,
|
||||||
} from '@altricade/core';
|
} from '@altricade/core';
|
||||||
import { HttpError } from '../../shared/http-error';
|
import { HttpError } from '../../shared/http-error';
|
||||||
|
import type { Publisher } from '../../shared/publisher';
|
||||||
import type { ConversationsRepository } from '../conversations';
|
import type { ConversationsRepository } from '../conversations';
|
||||||
import type { Deliver } from '../conversations';
|
import type { Deliver } from '../conversations';
|
||||||
import type { MessagesRepository } from './messages.repository';
|
import type { MessagesRepository } from './messages.repository';
|
||||||
|
|
@ -17,11 +23,21 @@ export interface MessagesServiceDeps {
|
||||||
messages: MessagesRepository;
|
messages: MessagesRepository;
|
||||||
conversations: ConversationsRepository;
|
conversations: ConversationsRepository;
|
||||||
deliver: Deliver;
|
deliver: Deliver;
|
||||||
mediaDownloadUrl: (objectKey: string) => Promise<string>;
|
/** Personal-channel publisher for per-user view-state events. */
|
||||||
|
publish: Publisher;
|
||||||
|
mediaDownloadUrl: (objectKey: string, origin: string | null) => Promise<string>;
|
||||||
/** Fire-and-forget push-notification hook, called for each newly-created message. */
|
/** Fire-and-forget push-notification hook, called for each newly-created message. */
|
||||||
notify: (message: Message) => void;
|
notify: (message: Message) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Telegram-style shared-media tabs → media kinds. Round video messages live in
|
||||||
|
// the Voice tab alongside voice notes (both are "instant" messages), not Media.
|
||||||
|
const TAB_KINDS: Record<MediaTab, string[]> = {
|
||||||
|
media: ['image', 'video'],
|
||||||
|
files: ['file'],
|
||||||
|
voice: ['voice', 'video_note'],
|
||||||
|
};
|
||||||
|
|
||||||
export interface SentMessage {
|
export interface SentMessage {
|
||||||
message: Message;
|
message: Message;
|
||||||
created: boolean;
|
created: boolean;
|
||||||
|
|
@ -35,6 +51,19 @@ export interface MessagesService {
|
||||||
beforeSeq: number | null,
|
beforeSeq: number | null,
|
||||||
limit: number,
|
limit: number,
|
||||||
): Promise<Message[]>;
|
): Promise<Message[]>;
|
||||||
|
historyAfter(
|
||||||
|
conversationId: string,
|
||||||
|
userId: string,
|
||||||
|
afterSeq: number,
|
||||||
|
limit: number,
|
||||||
|
): Promise<Message[]>;
|
||||||
|
/** Window centered on a message — jump-to-message / search result context. */
|
||||||
|
context(
|
||||||
|
conversationId: string,
|
||||||
|
userId: string,
|
||||||
|
messageId: string,
|
||||||
|
limit: number,
|
||||||
|
): Promise<Message[]>;
|
||||||
edit(
|
edit(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
|
|
@ -42,6 +71,24 @@ export interface MessagesService {
|
||||||
content: string,
|
content: string,
|
||||||
): Promise<Message>;
|
): Promise<Message>;
|
||||||
remove(conversationId: string, messageId: string, userId: string): Promise<void>;
|
remove(conversationId: string, messageId: string, userId: string): Promise<void>;
|
||||||
|
hide(conversationId: string, messageId: string, userId: string): Promise<void>;
|
||||||
|
forward(
|
||||||
|
targetConversationId: string,
|
||||||
|
userId: string,
|
||||||
|
sourceConversationId: string,
|
||||||
|
messageId: string,
|
||||||
|
hideSender: boolean,
|
||||||
|
): Promise<Message>;
|
||||||
|
pin(conversationId: string, messageId: string, userId: string): Promise<void>;
|
||||||
|
unpin(conversationId: string, messageId: string, userId: string): Promise<void>;
|
||||||
|
pinned(conversationId: string, userId: string): Promise<Message[]>;
|
||||||
|
media(
|
||||||
|
conversationId: string,
|
||||||
|
userId: string,
|
||||||
|
tab: MediaTab,
|
||||||
|
beforeSeq: number | null,
|
||||||
|
limit: number,
|
||||||
|
): Promise<Message[]>;
|
||||||
addReaction(
|
addReaction(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
|
|
@ -54,11 +101,16 @@ export interface MessagesService {
|
||||||
userId: string,
|
userId: string,
|
||||||
emoji: string,
|
emoji: string,
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
mediaUrl(conversationId: string, messageId: string, userId: string): Promise<string>;
|
mediaUrl(
|
||||||
|
conversationId: string,
|
||||||
|
messageId: string,
|
||||||
|
userId: string,
|
||||||
|
origin: string | null,
|
||||||
|
): Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createMessagesService = (deps: MessagesServiceDeps): MessagesService => {
|
export const createMessagesService = (deps: MessagesServiceDeps): MessagesService => {
|
||||||
const { messages, conversations, deliver, mediaDownloadUrl, notify } = deps;
|
const { messages, conversations, deliver, publish, mediaDownloadUrl, notify } = deps;
|
||||||
|
|
||||||
const assertMember = async (conversationId: string, userId: string): Promise<void> => {
|
const assertMember = async (conversationId: string, userId: string): Promise<void> => {
|
||||||
if (!(await conversations.isMember(conversationId, userId))) {
|
if (!(await conversations.isMember(conversationId, userId))) {
|
||||||
|
|
@ -81,9 +133,30 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic
|
||||||
return toMessage(row, reactions.get(messageId) ?? []);
|
return toMessage(row, reactions.get(messageId) ?? []);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Channels are broadcast: only the owner and admins may post.
|
||||||
|
const assertCanPost = async (conversationId: string, userId: string): Promise<void> => {
|
||||||
|
const conversation = await conversations.findById(conversationId);
|
||||||
|
if (conversation?.type !== 'channel') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const role = await conversations.getRole(conversationId, userId);
|
||||||
|
if (role !== 'owner' && role !== 'admin') {
|
||||||
|
throw new HttpError(403, 'not_channel_admin', 'Only channel admins can post');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
send: async (conversationId, senderId, input) => {
|
send: async (conversationId, senderId, input) => {
|
||||||
await assertMember(conversationId, senderId);
|
await assertMember(conversationId, senderId);
|
||||||
|
await assertCanPost(conversationId, senderId);
|
||||||
|
|
||||||
|
// Only honor a reply target that lives in this same conversation.
|
||||||
|
let replyToId: string | null = null;
|
||||||
|
if (input.replyToId !== undefined) {
|
||||||
|
if ((await messages.getConversationId(input.replyToId)) === conversationId) {
|
||||||
|
replyToId = input.replyToId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const inserted = await messages.insert({
|
const inserted = await messages.insert({
|
||||||
conversationId,
|
conversationId,
|
||||||
|
|
@ -94,6 +167,9 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic
|
||||||
encryption: input.encryption ?? null,
|
encryption: input.encryption ?? null,
|
||||||
mediaKey: input.mediaKey ?? null,
|
mediaKey: input.mediaKey ?? null,
|
||||||
media: input.media ?? null,
|
media: input.media ?? null,
|
||||||
|
replyToId,
|
||||||
|
forwarded: false,
|
||||||
|
forwardedFrom: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const id =
|
const id =
|
||||||
|
|
@ -120,7 +196,7 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic
|
||||||
|
|
||||||
history: async (conversationId, userId, beforeSeq, limit) => {
|
history: async (conversationId, userId, beforeSeq, limit) => {
|
||||||
await assertMember(conversationId, userId);
|
await assertMember(conversationId, userId);
|
||||||
const rows = await messages.listHistory(conversationId, beforeSeq, limit);
|
const rows = await messages.listHistory(conversationId, userId, beforeSeq, limit);
|
||||||
const reactions = await messages.reactionsFor(
|
const reactions = await messages.reactionsFor(
|
||||||
rows.map((row) => row.id),
|
rows.map((row) => row.id),
|
||||||
userId,
|
userId,
|
||||||
|
|
@ -128,6 +204,40 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic
|
||||||
return rows.map((row) => toMessage(row, reactions.get(row.id) ?? []));
|
return rows.map((row) => toMessage(row, reactions.get(row.id) ?? []));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
historyAfter: async (conversationId, userId, afterSeq, limit) => {
|
||||||
|
await assertMember(conversationId, userId);
|
||||||
|
const rows = await messages.listAfter(conversationId, userId, afterSeq, limit);
|
||||||
|
const reactions = await messages.reactionsFor(
|
||||||
|
rows.map((row) => row.id),
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
return rows.map((row) => toMessage(row, reactions.get(row.id) ?? []));
|
||||||
|
},
|
||||||
|
|
||||||
|
context: async (conversationId, userId, messageId, limit) => {
|
||||||
|
await assertMember(conversationId, userId);
|
||||||
|
const target = await messages.getWithSenderById(messageId);
|
||||||
|
if (target === undefined) {
|
||||||
|
throw new HttpError(404, 'not_found', 'Message not found');
|
||||||
|
}
|
||||||
|
if (target.conversation_id !== conversationId) {
|
||||||
|
throw new HttpError(404, 'not_found', 'Message not found');
|
||||||
|
}
|
||||||
|
const half = Math.max(1, Math.floor(limit / 2));
|
||||||
|
const rows = await messages.listContext(conversationId, userId, target.seq, half);
|
||||||
|
const reactions = await messages.reactionsFor(
|
||||||
|
rows.map((row) => row.id),
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
return rows.map((row) => toMessage(row, reactions.get(row.id) ?? []));
|
||||||
|
},
|
||||||
|
|
||||||
|
media: async (conversationId, userId, tab, beforeSeq, limit) => {
|
||||||
|
await assertMember(conversationId, userId);
|
||||||
|
const rows = await messages.listMedia(conversationId, userId, TAB_KINDS[tab], beforeSeq, limit);
|
||||||
|
return rows.map((row) => toMessage(row, []));
|
||||||
|
},
|
||||||
|
|
||||||
edit: async (conversationId, messageId, userId, content) => {
|
edit: async (conversationId, messageId, userId, content) => {
|
||||||
await assertMember(conversationId, userId);
|
await assertMember(conversationId, userId);
|
||||||
await assertMessageIn(conversationId, messageId);
|
await assertMessageIn(conversationId, messageId);
|
||||||
|
|
@ -144,14 +254,134 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic
|
||||||
remove: async (conversationId, messageId, userId) => {
|
remove: async (conversationId, messageId, userId) => {
|
||||||
await assertMember(conversationId, userId);
|
await assertMember(conversationId, userId);
|
||||||
await assertMessageIn(conversationId, messageId);
|
await assertMessageIn(conversationId, messageId);
|
||||||
const deleted = await messages.softDelete(messageId, userId);
|
let deleted = await messages.softDelete(messageId, userId);
|
||||||
if (deleted === undefined) {
|
if (deleted === undefined) {
|
||||||
throw new HttpError(403, 'not_deletable', 'You can only delete your own messages');
|
// Not the sender: the owner of a group/channel may moderate any message in it.
|
||||||
|
const conversation = await conversations.findById(conversationId);
|
||||||
|
const role = await conversations.getRole(conversationId, userId);
|
||||||
|
if (conversation === undefined || conversation.type === 'direct' || role !== 'owner') {
|
||||||
|
throw new HttpError(
|
||||||
|
403,
|
||||||
|
'not_deletable',
|
||||||
|
'You can only delete your own messages (group owners can delete any)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
deleted = await messages.softDeleteAny(messageId);
|
||||||
|
}
|
||||||
|
if (deleted === undefined) {
|
||||||
|
// Already deleted — idempotent success, nothing to broadcast.
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const event: MessageDeleteEvent = { type: EventType.MessageDelete, conversationId, messageId };
|
const event: MessageDeleteEvent = { type: EventType.MessageDelete, conversationId, messageId };
|
||||||
await deliver(conversationId, event);
|
await deliver(conversationId, event);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
hide: async (conversationId, messageId, userId) => {
|
||||||
|
await assertMember(conversationId, userId);
|
||||||
|
await assertMessageIn(conversationId, messageId);
|
||||||
|
await messages.hide(messageId, userId);
|
||||||
|
// Personal channel only — other participants are unaffected by design.
|
||||||
|
const event: MessageHiddenEvent = { type: EventType.MessageHidden, conversationId, messageId };
|
||||||
|
await publish(userChannel(userId), event);
|
||||||
|
},
|
||||||
|
|
||||||
|
forward: async (targetConversationId, userId, sourceConversationId, messageId, hideSender) => {
|
||||||
|
// Must belong to both ends, and be able to post into the target.
|
||||||
|
await assertMember(sourceConversationId, userId);
|
||||||
|
await assertMember(targetConversationId, userId);
|
||||||
|
await assertCanPost(targetConversationId, userId);
|
||||||
|
|
||||||
|
const source = await messages.getWithSenderById(messageId);
|
||||||
|
if (source === undefined) {
|
||||||
|
throw new HttpError(404, 'not_found', 'Message not found');
|
||||||
|
}
|
||||||
|
if (source.conversation_id !== sourceConversationId) {
|
||||||
|
throw new HttpError(404, 'not_found', 'Message not found');
|
||||||
|
}
|
||||||
|
if (source.deleted_at !== null) {
|
||||||
|
throw new HttpError(400, 'invalid_target', 'Cannot forward a deleted message');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attribution (Telegram-style): forwarding an already-forwarded message
|
||||||
|
// preserves the ORIGINAL origin; forwarding from a channel credits the
|
||||||
|
// channel (no profile link); otherwise credits the author (clickable).
|
||||||
|
let origin: ForwardOrigin | null;
|
||||||
|
if (source.forwarded) {
|
||||||
|
origin = source.forwarded_from;
|
||||||
|
} else {
|
||||||
|
const sourceConv = await conversations.findById(sourceConversationId);
|
||||||
|
if (sourceConv?.type === 'channel') {
|
||||||
|
origin = { name: sourceConv.title ?? 'Channel', user: null };
|
||||||
|
} else {
|
||||||
|
origin = {
|
||||||
|
name: source.sender_display_name,
|
||||||
|
user: {
|
||||||
|
id: source.sender_id,
|
||||||
|
username: source.sender_username,
|
||||||
|
displayName: source.sender_display_name,
|
||||||
|
avatarUrl: source.sender_avatar_ref,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientMsgId = randomUUID();
|
||||||
|
const inserted = await messages.insert({
|
||||||
|
conversationId: targetConversationId,
|
||||||
|
senderId: userId,
|
||||||
|
clientMsgId,
|
||||||
|
content: source.content,
|
||||||
|
contentType: source.content_type,
|
||||||
|
encryption: source.encryption,
|
||||||
|
// Object storage is shared — reference the same media object.
|
||||||
|
mediaKey: source.media_key,
|
||||||
|
media: source.media_meta,
|
||||||
|
replyToId: null,
|
||||||
|
forwarded: true,
|
||||||
|
forwardedFrom: hideSender ? null : origin,
|
||||||
|
});
|
||||||
|
const id =
|
||||||
|
inserted?.id ??
|
||||||
|
(await messages.findIdByDedupe(targetConversationId, userId, clientMsgId));
|
||||||
|
if (id === undefined) {
|
||||||
|
throw new HttpError(500, 'internal_error', 'Forward could not be persisted');
|
||||||
|
}
|
||||||
|
const row = await messages.getWithSenderById(id);
|
||||||
|
if (row === undefined) {
|
||||||
|
throw new HttpError(500, 'internal_error', 'Message not found after forward');
|
||||||
|
}
|
||||||
|
const message = toMessage(row, []);
|
||||||
|
await conversations.touchLastMessage(targetConversationId);
|
||||||
|
const event: MessageNewEvent = { type: EventType.MessageNew, message };
|
||||||
|
await deliver(targetConversationId, event);
|
||||||
|
notify(message);
|
||||||
|
return message;
|
||||||
|
},
|
||||||
|
|
||||||
|
pin: async (conversationId, messageId, userId) => {
|
||||||
|
await assertMember(conversationId, userId);
|
||||||
|
await assertMessageIn(conversationId, messageId);
|
||||||
|
await assertCanPost(conversationId, userId); // channels: admins only
|
||||||
|
await messages.pin(conversationId, messageId, userId);
|
||||||
|
const event: MessagePinEvent = { type: EventType.MessagePin, conversationId, messageId };
|
||||||
|
await deliver(conversationId, event);
|
||||||
|
},
|
||||||
|
|
||||||
|
unpin: async (conversationId, messageId, userId) => {
|
||||||
|
await assertMember(conversationId, userId);
|
||||||
|
await assertMessageIn(conversationId, messageId);
|
||||||
|
await assertCanPost(conversationId, userId);
|
||||||
|
await messages.unpin(conversationId, messageId);
|
||||||
|
const event: MessagePinEvent = { type: EventType.MessageUnpin, conversationId, messageId };
|
||||||
|
await deliver(conversationId, event);
|
||||||
|
},
|
||||||
|
|
||||||
|
pinned: async (conversationId, userId) => {
|
||||||
|
await assertMember(conversationId, userId);
|
||||||
|
const rows = await messages.listPinned(conversationId);
|
||||||
|
return rows.map((row) => toMessage(row, []));
|
||||||
|
},
|
||||||
|
|
||||||
addReaction: async (conversationId, messageId, userId, emoji) => {
|
addReaction: async (conversationId, messageId, userId, emoji) => {
|
||||||
await assertMember(conversationId, userId);
|
await assertMember(conversationId, userId);
|
||||||
await assertMessageIn(conversationId, messageId);
|
await assertMessageIn(conversationId, messageId);
|
||||||
|
|
@ -180,7 +410,7 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic
|
||||||
await deliver(conversationId, event);
|
await deliver(conversationId, event);
|
||||||
},
|
},
|
||||||
|
|
||||||
mediaUrl: async (conversationId, messageId, userId) => {
|
mediaUrl: async (conversationId, messageId, userId, origin) => {
|
||||||
await assertMember(conversationId, userId);
|
await assertMember(conversationId, userId);
|
||||||
await assertMessageIn(conversationId, messageId);
|
await assertMessageIn(conversationId, messageId);
|
||||||
const row = await messages.getWithSenderById(messageId);
|
const row = await messages.getWithSenderById(messageId);
|
||||||
|
|
@ -188,7 +418,7 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic
|
||||||
if (mediaKey === null) {
|
if (mediaKey === null) {
|
||||||
throw new HttpError(404, 'not_found', 'No media on this message');
|
throw new HttpError(404, 'not_found', 'No media on this message');
|
||||||
}
|
}
|
||||||
return mediaDownloadUrl(mediaKey);
|
return mediaDownloadUrl(mediaKey, origin);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ const mediaPlaceholder = (kind: MediaKind): string => {
|
||||||
return 'Photo';
|
return 'Photo';
|
||||||
case 'video':
|
case 'video':
|
||||||
return 'Video';
|
return 'Video';
|
||||||
|
case 'video_note':
|
||||||
|
return 'Video message';
|
||||||
case 'voice':
|
case 'voice':
|
||||||
return 'Voice message';
|
return 'Voice message';
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export interface NewUser {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProfilePatch {
|
export interface ProfilePatch {
|
||||||
|
username?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
|
|
@ -58,7 +59,13 @@ export const createUsersRepository = (db: Kysely<Database>): UsersRepository =>
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.where('id', '!=', excludeUserId)
|
.where('id', '!=', excludeUserId)
|
||||||
.where((eb) =>
|
.where((eb) =>
|
||||||
eb.or([eb('username', 'ilike', `${query}%`), eb('display_name', 'ilike', `${query}%`)]),
|
eb.or([
|
||||||
|
eb('username', 'ilike', `${query}%`),
|
||||||
|
eb('display_name', 'ilike', `${query}%`),
|
||||||
|
// Phone lookup (prefix): matches with or without the leading '+'.
|
||||||
|
eb('phone', 'like', `${query}%`),
|
||||||
|
eb('phone', 'like', `+${query}%`),
|
||||||
|
]),
|
||||||
)
|
)
|
||||||
.orderBy('username', 'asc')
|
.orderBy('username', 'asc')
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
|
|
@ -66,6 +73,9 @@ export const createUsersRepository = (db: Kysely<Database>): UsersRepository =>
|
||||||
|
|
||||||
updateProfile: (id, patch) => {
|
updateProfile: (id, patch) => {
|
||||||
const values: Updateable<UsersTable> = { updated_at: new Date() };
|
const values: Updateable<UsersTable> = { updated_at: new Date() };
|
||||||
|
if (patch.username !== undefined) {
|
||||||
|
values.username = patch.username;
|
||||||
|
}
|
||||||
if (patch.displayName !== undefined) {
|
if (patch.displayName !== undefined) {
|
||||||
values.display_name = patch.displayName;
|
values.display_name = patch.displayName;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,16 @@ export const createUsersService = ({ users, publish }: UsersServiceDeps): UsersS
|
||||||
},
|
},
|
||||||
|
|
||||||
updateProfile: async (userId, patch) => {
|
updateProfile: async (userId, patch) => {
|
||||||
const row = await users.updateProfile(userId, patch);
|
let row;
|
||||||
|
try {
|
||||||
|
row = await users.updateProfile(userId, patch);
|
||||||
|
} catch (error) {
|
||||||
|
// Unique violation on the citext username column (23505) → taken.
|
||||||
|
if (typeof error === 'object' && error !== null && 'code' in error && error.code === '23505') {
|
||||||
|
throw new HttpError(409, 'username_taken', 'That username is already taken');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
if (row === undefined) {
|
if (row === undefined) {
|
||||||
throw new HttpError(404, 'not_found', 'User not found');
|
throw new HttpError(404, 'not_found', 'User not found');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ import { Client } from 'minio';
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
minio: Client;
|
minio: Client;
|
||||||
// Client configured with the browser-facing host — used ONLY for presigning
|
// Returns a client configured for the given public origin (e.g. the host a
|
||||||
// upload/download URLs so the signature matches the host the browser hits.
|
// browser or phone reached the gateway on) — used ONLY for presigning, so
|
||||||
minioPublic: Client;
|
// the S3 signature matches the host the device will actually hit. Falls
|
||||||
|
// back to the configured MINIO_PUBLIC_URL when origin is null.
|
||||||
|
minioPresign: (origin: string | null) => Client;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -18,20 +20,30 @@ export const minioPlugin = fp(
|
||||||
const client = new Client({ endPoint: endpoint, port, useSSL, accessKey, secretKey, region });
|
const client = new Client({ endPoint: endpoint, port, useSSL, accessKey, secretKey, region });
|
||||||
app.decorate('minio', client);
|
app.decorate('minio', client);
|
||||||
|
|
||||||
const parsed = new URL(publicUrl);
|
// One presigning client per public origin, cached — presigning is purely
|
||||||
const publicSecure = parsed.protocol === 'https:';
|
// computational (explicit region → no getBucketRegion network call to a
|
||||||
const publicPort = parsed.port === '' ? (publicSecure ? 443 : 80) : Number.parseInt(parsed.port, 10);
|
// host that's unreachable from inside the container).
|
||||||
// Explicit region so presigning is purely computational — no getBucketRegion
|
const presignClients = new Map<string, Client>();
|
||||||
// network call to the browser-facing host (unreachable from inside the container).
|
const clientFor = (base: string): Client => {
|
||||||
const publicClient = new Client({
|
const cached = presignClients.get(base);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const parsed = new URL(base);
|
||||||
|
const secure = parsed.protocol === 'https:';
|
||||||
|
const parsedPort = parsed.port === '' ? (secure ? 443 : 80) : Number.parseInt(parsed.port, 10);
|
||||||
|
const created = new Client({
|
||||||
endPoint: parsed.hostname,
|
endPoint: parsed.hostname,
|
||||||
port: publicPort,
|
port: parsedPort,
|
||||||
useSSL: publicSecure,
|
useSSL: secure,
|
||||||
accessKey,
|
accessKey,
|
||||||
secretKey,
|
secretKey,
|
||||||
region,
|
region,
|
||||||
});
|
});
|
||||||
app.decorate('minioPublic', publicClient);
|
presignClients.set(base, created);
|
||||||
|
return created;
|
||||||
|
};
|
||||||
|
app.decorate('minioPresign', (origin: string | null) => clientFor(origin ?? publicUrl));
|
||||||
|
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export const swaggerPlugin = fp(
|
||||||
await app.register(swagger, {
|
await app.register(swagger, {
|
||||||
openapi: {
|
openapi: {
|
||||||
info: {
|
info: {
|
||||||
title: 'Altricade API',
|
title: 'Zovi API',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
description: 'Realtime chat & calls backend.',
|
description: 'Realtime chat & calls backend.',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
15
packages/backend/src/shared/request-origin.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import type { FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
|
// The public origin the client used to reach the gateway (nginx forwards the
|
||||||
|
// original Host verbatim and stamps X-Forwarded-Proto). Presigning media URLs
|
||||||
|
// for THIS origin is what lets one backend serve browsers on localhost and
|
||||||
|
// phones on a LAN IP simultaneously. Null when the request didn't come through
|
||||||
|
// the proxy (direct dev access) — callers fall back to the configured URL.
|
||||||
|
export const requestOrigin = (request: FastifyRequest): string | null => {
|
||||||
|
const proto = request.headers['x-forwarded-proto'];
|
||||||
|
const host = request.headers.host;
|
||||||
|
if (typeof proto === 'string' && proto.length > 0 && typeof host === 'string' && host.length > 0) {
|
||||||
|
return `${proto.split(',')[0] ?? proto}://${host}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
@ -22,12 +22,33 @@ export const register = async (config: ApiClientConfig, body: RegisterBody): Pro
|
||||||
export const login = async (config: ApiClientConfig, body: LoginBody): Promise<AuthResult> =>
|
export const login = async (config: ApiClientConfig, body: LoginBody): Promise<AuthResult> =>
|
||||||
parse(authResultV, await requestJson(config, 'POST', '/auth/login', body));
|
parse(authResultV, await requestJson(config, 'POST', '/auth/login', body));
|
||||||
|
|
||||||
// Uses the httpOnly refresh cookie — no body.
|
// Cookie mode: uses the httpOnly refresh cookie (no body). Token mode: sends the
|
||||||
export const refresh = async (config: ApiClientConfig): Promise<AuthResult> =>
|
// stored refresh token in the body. Single-flighted: refresh rotates the token,
|
||||||
parse(authResultV, await requestJson(config, 'POST', '/auth/refresh'));
|
// so concurrent calls (double-mounted bootstrap, features racing on a 401) would
|
||||||
|
// replay it and trip the server's reuse detection — all callers share one request.
|
||||||
|
let inflightRefresh: Promise<AuthResult> | null = null;
|
||||||
|
|
||||||
|
const refreshBody = (config: ApiClientConfig): { refreshToken: string } | undefined => {
|
||||||
|
if (config.authMode !== 'token') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const token = config.getRefreshToken?.() ?? null;
|
||||||
|
return token === null ? undefined : { refreshToken: token };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const refresh = (config: ApiClientConfig): Promise<AuthResult> => {
|
||||||
|
inflightRefresh ??= (async () => {
|
||||||
|
try {
|
||||||
|
return parse(authResultV, await requestJson(config, 'POST', '/auth/refresh', refreshBody(config)));
|
||||||
|
} finally {
|
||||||
|
inflightRefresh = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return inflightRefresh;
|
||||||
|
};
|
||||||
|
|
||||||
export const logout = async (config: ApiClientConfig): Promise<void> => {
|
export const logout = async (config: ApiClientConfig): Promise<void> => {
|
||||||
await requestJson(config, 'POST', '/auth/logout');
|
await requestJson(config, 'POST', '/auth/logout', refreshBody(config));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const logoutAll = async (config: ApiClientConfig): Promise<void> => {
|
export const logoutAll = async (config: ApiClientConfig): Promise<void> => {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,13 @@ import {
|
||||||
conversationListSchema,
|
conversationListSchema,
|
||||||
conversationMemberListSchema,
|
conversationMemberListSchema,
|
||||||
} from '../schemas/index';
|
} from '../schemas/index';
|
||||||
import type { CreateDirectBody, CreateGroupBody, AddMemberBody } from '../schemas/index';
|
import type {
|
||||||
|
CreateDirectBody,
|
||||||
|
CreateGroupBody,
|
||||||
|
CreateChannelBody,
|
||||||
|
AddMemberBody,
|
||||||
|
SetAvatarBody,
|
||||||
|
} from '../schemas/index';
|
||||||
import type { Conversation, ConversationMember } from '../types/index';
|
import type { Conversation, ConversationMember } from '../types/index';
|
||||||
import { compileValidator, parse, requestJson } from './http';
|
import { compileValidator, parse, requestJson } from './http';
|
||||||
import type { ApiClientConfig } from './http';
|
import type { ApiClientConfig } from './http';
|
||||||
|
|
@ -33,6 +39,12 @@ export const createGroup = async (
|
||||||
): Promise<Conversation> =>
|
): Promise<Conversation> =>
|
||||||
parse(conversationV, await requestJson(config, 'POST', '/conversations', body));
|
parse(conversationV, await requestJson(config, 'POST', '/conversations', body));
|
||||||
|
|
||||||
|
export const createChannel = async (
|
||||||
|
config: ApiClientConfig,
|
||||||
|
body: CreateChannelBody,
|
||||||
|
): Promise<Conversation> =>
|
||||||
|
parse(conversationV, await requestJson(config, 'POST', '/conversations/channel', body));
|
||||||
|
|
||||||
export const listMembers = async (
|
export const listMembers = async (
|
||||||
config: ApiClientConfig,
|
config: ApiClientConfig,
|
||||||
id: string,
|
id: string,
|
||||||
|
|
@ -61,3 +73,21 @@ export const markRead = async (
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
await requestJson(config, 'POST', `/conversations/${id}/read`, { seq });
|
await requestJson(config, 'POST', `/conversations/${id}/read`, { seq });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Set a group/channel photo from an uploaded avatar object key (owner/admin).
|
||||||
|
export const setConversationAvatar = async (
|
||||||
|
config: ApiClientConfig,
|
||||||
|
id: string,
|
||||||
|
body: SetAvatarBody,
|
||||||
|
): Promise<Conversation> =>
|
||||||
|
parse(conversationV, await requestJson(config, 'POST', `/conversations/${id}/avatar`, body));
|
||||||
|
|
||||||
|
// "Clear history" (for me): hides all current messages; the chat stays listed.
|
||||||
|
export const clearConversation = async (config: ApiClientConfig, id: string): Promise<void> => {
|
||||||
|
await requestJson(config, 'POST', `/conversations/${id}/clear`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// "Delete chat" (for me): clear + remove from the list until new activity.
|
||||||
|
export const hideConversation = async (config: ApiClientConfig, id: string): Promise<void> => {
|
||||||
|
await requestJson(config, 'POST', `/conversations/${id}/hide`);
|
||||||
|
};
|
||||||
|
|
|
||||||
35
packages/core/src/api/folders.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { foldersStateSchema } from '../schemas/index';
|
||||||
|
import type { CreateFolderBody, UpdateFolderBody, SetPinBody } from '../schemas/index';
|
||||||
|
import type { FoldersState } from '../types/index';
|
||||||
|
import { compileValidator, parse, requestJson } from './http';
|
||||||
|
import type { ApiClientConfig } from './http';
|
||||||
|
|
||||||
|
const foldersStateV = compileValidator<FoldersState>(foldersStateSchema);
|
||||||
|
|
||||||
|
// Every mutation returns the full fresh snapshot (and the backend broadcasts
|
||||||
|
// the same snapshot to the user's personal channel for other devices).
|
||||||
|
|
||||||
|
export const getFolders = async (config: ApiClientConfig): Promise<FoldersState> =>
|
||||||
|
parse(foldersStateV, await requestJson(config, 'GET', '/folders'));
|
||||||
|
|
||||||
|
export const createFolder = async (
|
||||||
|
config: ApiClientConfig,
|
||||||
|
body: CreateFolderBody,
|
||||||
|
): Promise<FoldersState> =>
|
||||||
|
parse(foldersStateV, await requestJson(config, 'POST', '/folders', body));
|
||||||
|
|
||||||
|
export const updateFolder = async (
|
||||||
|
config: ApiClientConfig,
|
||||||
|
folderId: string,
|
||||||
|
body: UpdateFolderBody,
|
||||||
|
): Promise<FoldersState> =>
|
||||||
|
parse(foldersStateV, await requestJson(config, 'PATCH', `/folders/${folderId}`, body));
|
||||||
|
|
||||||
|
export const deleteFolder = async (
|
||||||
|
config: ApiClientConfig,
|
||||||
|
folderId: string,
|
||||||
|
): Promise<FoldersState> =>
|
||||||
|
parse(foldersStateV, await requestJson(config, 'DELETE', `/folders/${folderId}`));
|
||||||
|
|
||||||
|
export const setPin = async (config: ApiClientConfig, body: SetPinBody): Promise<FoldersState> =>
|
||||||
|
parse(foldersStateV, await requestJson(config, 'PUT', '/folders/pins', body));
|
||||||
|
|
@ -25,6 +25,15 @@ export interface ApiClientConfig {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
/** Supplies the current access token for the Authorization header, if any. */
|
/** Supplies the current access token for the Authorization header, if any. */
|
||||||
getAccessToken?: () => string | null;
|
getAccessToken?: () => string | null;
|
||||||
|
/**
|
||||||
|
* Refresh-token transport. 'cookie' (default) uses the httpOnly refresh cookie
|
||||||
|
* (web). 'token' (native) sends `X-Auth-Mode: token`; the backend then returns
|
||||||
|
* the refresh token in the body and accepts it from the body — cookies are
|
||||||
|
* unreliable in React Native.
|
||||||
|
*/
|
||||||
|
authMode?: 'cookie' | 'token';
|
||||||
|
/** Current stored refresh token (token mode only). */
|
||||||
|
getRefreshToken?: () => string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toApiError = (status: number, json: unknown): ApiError => {
|
const toApiError = (status: number, json: unknown): ApiError => {
|
||||||
|
|
@ -45,6 +54,7 @@ export const requestJson = async (
|
||||||
path: string,
|
path: string,
|
||||||
body?: unknown,
|
body?: unknown,
|
||||||
): Promise<unknown> => {
|
): Promise<unknown> => {
|
||||||
|
const tokenMode = config.authMode === 'token';
|
||||||
const headers: Record<string, string> = { accept: 'application/json' };
|
const headers: Record<string, string> = { accept: 'application/json' };
|
||||||
if (body !== undefined) {
|
if (body !== undefined) {
|
||||||
headers['content-type'] = 'application/json';
|
headers['content-type'] = 'application/json';
|
||||||
|
|
@ -53,11 +63,16 @@ export const requestJson = async (
|
||||||
if (token !== null) {
|
if (token !== null) {
|
||||||
headers['authorization'] = `Bearer ${token}`;
|
headers['authorization'] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
if (tokenMode) {
|
||||||
|
headers['x-auth-mode'] = 'token';
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${config.baseUrl}${path}`, {
|
const response = await fetch(`${config.baseUrl}${path}`, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
credentials: 'include',
|
// Cookie mode relies on the refresh cookie; token mode carries the refresh
|
||||||
|
// token explicitly, so no ambient credentials are needed.
|
||||||
|
credentials: tokenMode ? 'omit' : 'include',
|
||||||
body: body === undefined ? null : JSON.stringify(body),
|
body: body === undefined ? null : JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,30 +13,42 @@ export {
|
||||||
getCentrifugoToken,
|
getCentrifugoToken,
|
||||||
} from './auth';
|
} from './auth';
|
||||||
export { sendEcho } from './realtime';
|
export { sendEcho } from './realtime';
|
||||||
export { searchUsers, setAvatar } from './users';
|
export { searchUsers, getUserByUsername, setAvatar } from './users';
|
||||||
export {
|
export {
|
||||||
listConversations,
|
listConversations,
|
||||||
getConversation,
|
getConversation,
|
||||||
createDirect,
|
createDirect,
|
||||||
createGroup,
|
createGroup,
|
||||||
|
createChannel,
|
||||||
|
setConversationAvatar,
|
||||||
listMembers,
|
listMembers,
|
||||||
addMember,
|
addMember,
|
||||||
removeMember,
|
removeMember,
|
||||||
|
clearConversation,
|
||||||
|
hideConversation,
|
||||||
} from './conversations';
|
} from './conversations';
|
||||||
export { listContacts, addContact, removeContact } from './contacts';
|
export { listContacts, addContact, removeContact } from './contacts';
|
||||||
export {
|
export {
|
||||||
sendMessage,
|
sendMessage,
|
||||||
getHistory,
|
getHistory,
|
||||||
|
getMessageContext,
|
||||||
editMessage,
|
editMessage,
|
||||||
deleteMessage,
|
deleteMessage,
|
||||||
|
hideMessage,
|
||||||
|
forwardMessage,
|
||||||
|
pinMessage,
|
||||||
|
unpinMessage,
|
||||||
|
listPinned,
|
||||||
|
listConversationMedia,
|
||||||
addReaction,
|
addReaction,
|
||||||
removeReaction,
|
removeReaction,
|
||||||
} from './messages';
|
} from './messages';
|
||||||
|
export { getFolders, createFolder, updateFolder, deleteFolder, setPin } from './folders';
|
||||||
export type { HistoryOptions } from './messages';
|
export type { HistoryOptions } from './messages';
|
||||||
export { markRead } from './conversations';
|
export { markRead } from './conversations';
|
||||||
export { getPresence, heartbeat } from './presence';
|
export { getPresence, heartbeat } from './presence';
|
||||||
export { getUploadUrl, getAvatarUploadUrl, getMediaUrl, uploadToUrl } from './media';
|
export { getUploadUrl, getAvatarUploadUrl, getMediaUrl, uploadToUrl } from './media';
|
||||||
export type { UploadTarget, AvatarTarget } from './media';
|
export type { UploadTarget, AvatarTarget, UploadOptions } from './media';
|
||||||
export {
|
export {
|
||||||
getVapidPublicKey,
|
getVapidPublicKey,
|
||||||
registerDevice,
|
registerDevice,
|
||||||
|
|
|
||||||
|
|
@ -42,14 +42,61 @@ export const getMediaUrl = async (
|
||||||
return result.url;
|
return result.url;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface UploadOptions {
|
||||||
|
/** Called with uploaded/total bytes as the transfer advances. */
|
||||||
|
onProgress?: (loaded: number, total: number) => void;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
// Direct PUT of the file bytes to the presigned MinIO URL (no auth/credentials).
|
// Direct PUT of the file bytes to the presigned MinIO URL (no auth/credentials).
|
||||||
export const uploadToUrl = async (uploadUrl: string, file: Blob, mime: string): Promise<void> => {
|
// XHR instead of fetch: fetch cannot report upload progress, and XHR is
|
||||||
const response = await fetch(uploadUrl, {
|
// available in both browsers and React Native.
|
||||||
method: 'PUT',
|
export const uploadToUrl = (
|
||||||
headers: { 'content-type': mime },
|
uploadUrl: string,
|
||||||
body: file,
|
file: Blob,
|
||||||
});
|
mime: string,
|
||||||
if (!response.ok) {
|
options: UploadOptions = {},
|
||||||
throw new Error(`Upload failed with status ${String(response.status)}`);
|
): Promise<void> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const { onProgress, signal } = options;
|
||||||
|
if (signal?.aborted === true) {
|
||||||
|
reject(new Error('Upload aborted'));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
};
|
const xhr = new XMLHttpRequest();
|
||||||
|
const onAbort = (): void => {
|
||||||
|
xhr.abort();
|
||||||
|
};
|
||||||
|
signal?.addEventListener('abort', onAbort, { once: true });
|
||||||
|
const settle = (outcome: () => void): void => {
|
||||||
|
signal?.removeEventListener('abort', onAbort);
|
||||||
|
outcome();
|
||||||
|
};
|
||||||
|
xhr.upload.onprogress = (event) => {
|
||||||
|
if (event.lengthComputable) {
|
||||||
|
onProgress?.(event.loaded, event.total);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.onload = () => {
|
||||||
|
settle(() => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Upload failed with status ${String(xhr.status)}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
xhr.onerror = () => {
|
||||||
|
settle(() => {
|
||||||
|
reject(new Error('Upload failed: network error'));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
xhr.onabort = () => {
|
||||||
|
settle(() => {
|
||||||
|
reject(new Error('Upload aborted'));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
xhr.open('PUT', uploadUrl);
|
||||||
|
xhr.setRequestHeader('content-type', mime);
|
||||||
|
xhr.send(file);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import { messageSchema, messageListSchema } from '../schemas/index';
|
import { messageSchema, messageListSchema } from '../schemas/index';
|
||||||
import type { SendMessageBody, EditMessageBody, ReactionBody } from '../schemas/index';
|
import type {
|
||||||
|
SendMessageBody,
|
||||||
|
EditMessageBody,
|
||||||
|
ReactionBody,
|
||||||
|
ForwardMessageBody,
|
||||||
|
MediaTab,
|
||||||
|
} from '../schemas/index';
|
||||||
import type { Message } from '../types/index';
|
import type { Message } from '../types/index';
|
||||||
import { compileValidator, parse, requestJson } from './http';
|
import { compileValidator, parse, requestJson } from './http';
|
||||||
import type { ApiClientConfig } from './http';
|
import type { ApiClientConfig } from './http';
|
||||||
|
|
@ -16,6 +22,8 @@ export const sendMessage = async (
|
||||||
|
|
||||||
export interface HistoryOptions {
|
export interface HistoryOptions {
|
||||||
before?: number;
|
before?: number;
|
||||||
|
/** Ascending page after this seq (downward pagination from a jumped context). */
|
||||||
|
after?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -28,6 +36,9 @@ export const getHistory = async (
|
||||||
if (options.before !== undefined) {
|
if (options.before !== undefined) {
|
||||||
params.set('before', String(options.before));
|
params.set('before', String(options.before));
|
||||||
}
|
}
|
||||||
|
if (options.after !== undefined) {
|
||||||
|
params.set('after', String(options.after));
|
||||||
|
}
|
||||||
if (options.limit !== undefined) {
|
if (options.limit !== undefined) {
|
||||||
params.set('limit', String(options.limit));
|
params.set('limit', String(options.limit));
|
||||||
}
|
}
|
||||||
|
|
@ -37,6 +48,23 @@ export const getHistory = async (
|
||||||
return parse(messageListV, await requestJson(config, 'GET', path));
|
return parse(messageListV, await requestJson(config, 'GET', path));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// History window centered on a message — the jump-to-message primitive shared
|
||||||
|
// by reply quotes, the pinned bar, and (later) search results.
|
||||||
|
export const getMessageContext = async (
|
||||||
|
config: ApiClientConfig,
|
||||||
|
conversationId: string,
|
||||||
|
messageId: string,
|
||||||
|
limit = 50,
|
||||||
|
): Promise<Message[]> =>
|
||||||
|
parse(
|
||||||
|
messageListV,
|
||||||
|
await requestJson(
|
||||||
|
config,
|
||||||
|
'GET',
|
||||||
|
`/conversations/${conversationId}/messages/${messageId}/context?limit=${String(limit)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
export const editMessage = async (
|
export const editMessage = async (
|
||||||
config: ApiClientConfig,
|
config: ApiClientConfig,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
|
|
@ -48,6 +76,7 @@ export const editMessage = async (
|
||||||
await requestJson(config, 'PATCH', `/conversations/${conversationId}/messages/${messageId}`, body),
|
await requestJson(config, 'PATCH', `/conversations/${conversationId}/messages/${messageId}`, body),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// "Delete for everyone" — allowed for the sender, or the group owner (moderation).
|
||||||
export const deleteMessage = async (
|
export const deleteMessage = async (
|
||||||
config: ApiClientConfig,
|
config: ApiClientConfig,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
|
|
@ -56,6 +85,67 @@ export const deleteMessage = async (
|
||||||
await requestJson(config, 'DELETE', `/conversations/${conversationId}/messages/${messageId}`);
|
await requestJson(config, 'DELETE', `/conversations/${conversationId}/messages/${messageId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// "Delete for me" — hides the message for the acting user only.
|
||||||
|
export const hideMessage = async (
|
||||||
|
config: ApiClientConfig,
|
||||||
|
conversationId: string,
|
||||||
|
messageId: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
await requestJson(config, 'POST', `/conversations/${conversationId}/messages/${messageId}/hide`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Forward a message into `targetConversationId` (copies content/media with
|
||||||
|
// attribution). Returns the new message in the target.
|
||||||
|
export const forwardMessage = async (
|
||||||
|
config: ApiClientConfig,
|
||||||
|
targetConversationId: string,
|
||||||
|
body: ForwardMessageBody,
|
||||||
|
): Promise<Message> =>
|
||||||
|
parse(
|
||||||
|
messageV,
|
||||||
|
await requestJson(config, 'POST', `/conversations/${targetConversationId}/forward`, body),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const pinMessage = async (
|
||||||
|
config: ApiClientConfig,
|
||||||
|
conversationId: string,
|
||||||
|
messageId: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
await requestJson(config, 'POST', `/conversations/${conversationId}/messages/${messageId}/pin`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unpinMessage = async (
|
||||||
|
config: ApiClientConfig,
|
||||||
|
conversationId: string,
|
||||||
|
messageId: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
await requestJson(config, 'DELETE', `/conversations/${conversationId}/messages/${messageId}/pin`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pinned messages for a conversation, newest pin first.
|
||||||
|
export const listPinned = async (
|
||||||
|
config: ApiClientConfig,
|
||||||
|
conversationId: string,
|
||||||
|
): Promise<Message[]> =>
|
||||||
|
parse(messageListV, await requestJson(config, 'GET', `/conversations/${conversationId}/pinned`));
|
||||||
|
|
||||||
|
// Shared media for the profile panel, newest first, keyset-paginated by seq.
|
||||||
|
export const listConversationMedia = async (
|
||||||
|
config: ApiClientConfig,
|
||||||
|
conversationId: string,
|
||||||
|
tab: MediaTab,
|
||||||
|
before?: number,
|
||||||
|
): Promise<Message[]> => {
|
||||||
|
const params = new URLSearchParams({ tab });
|
||||||
|
if (before !== undefined) {
|
||||||
|
params.set('before', String(before));
|
||||||
|
}
|
||||||
|
return parse(
|
||||||
|
messageListV,
|
||||||
|
await requestJson(config, 'GET', `/conversations/${conversationId}/media?${params.toString()}`),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const addReaction = async (
|
export const addReaction = async (
|
||||||
config: ApiClientConfig,
|
config: ApiClientConfig,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { publicUserListSchema, userSchema } from '../schemas/index';
|
import { publicUserSchema, publicUserListSchema, userSchema } from '../schemas/index';
|
||||||
import type { SetAvatarBody } from '../schemas/index';
|
import type { SetAvatarBody } from '../schemas/index';
|
||||||
import type { PublicUser, User } from '../types/index';
|
import type { PublicUser, User } from '../types/index';
|
||||||
import { compileValidator, parse, requestJson } from './http';
|
import { compileValidator, parse, requestJson } from './http';
|
||||||
import type { ApiClientConfig } from './http';
|
import type { ApiClientConfig } from './http';
|
||||||
|
|
||||||
|
const publicUserV = compileValidator<PublicUser>(publicUserSchema);
|
||||||
const publicUserListV = compileValidator<PublicUser[]>(publicUserListSchema);
|
const publicUserListV = compileValidator<PublicUser[]>(publicUserListSchema);
|
||||||
const userV = compileValidator<User>(userSchema);
|
const userV = compileValidator<User>(userSchema);
|
||||||
|
|
||||||
|
|
@ -16,5 +17,11 @@ export const searchUsers = async (
|
||||||
await requestJson(config, 'GET', `/users/search?q=${encodeURIComponent(query)}`),
|
await requestJson(config, 'GET', `/users/search?q=${encodeURIComponent(query)}`),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getUserByUsername = async (
|
||||||
|
config: ApiClientConfig,
|
||||||
|
username: string,
|
||||||
|
): Promise<PublicUser> =>
|
||||||
|
parse(publicUserV, await requestJson(config, 'GET', `/users/${encodeURIComponent(username)}`));
|
||||||
|
|
||||||
export const setAvatar = async (config: ApiClientConfig, body: SetAvatarBody): Promise<User> =>
|
export const setAvatar = async (config: ApiClientConfig, body: SetAvatarBody): Promise<User> =>
|
||||||
parse(userV, await requestJson(config, 'POST', '/me/avatar', body));
|
parse(userV, await requestJson(config, 'POST', '/me/avatar', body));
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
|
|
||||||
import type { Message } from '../types/message';
|
import type { Message } from '../types/message';
|
||||||
import type { Conversation } from '../types/conversation';
|
import type { Conversation } from '../types/conversation';
|
||||||
|
import type { FoldersState } from '../types/folders';
|
||||||
|
|
||||||
export const EventType = {
|
export const EventType = {
|
||||||
// Bucket A
|
// Bucket A
|
||||||
|
|
@ -19,11 +20,19 @@ export const EventType = {
|
||||||
MessageDelete: 'message.delete',
|
MessageDelete: 'message.delete',
|
||||||
ReactionAdd: 'reaction.add',
|
ReactionAdd: 'reaction.add',
|
||||||
ReactionRemove: 'reaction.remove',
|
ReactionRemove: 'reaction.remove',
|
||||||
|
MessagePin: 'message.pin',
|
||||||
|
MessageUnpin: 'message.unpin',
|
||||||
ReadReceipt: 'read.receipt',
|
ReadReceipt: 'read.receipt',
|
||||||
LastSeen: 'last_seen',
|
LastSeen: 'last_seen',
|
||||||
ConversationNew: 'conversation.new',
|
ConversationNew: 'conversation.new',
|
||||||
ConversationMembership: 'conversation.membership',
|
ConversationMembership: 'conversation.membership',
|
||||||
ProfileUpdate: 'profile.update',
|
ProfileUpdate: 'profile.update',
|
||||||
|
// Per-user view state, published to the acting user's personal channel so
|
||||||
|
// their other devices stay in sync.
|
||||||
|
MessageHidden: 'message.hidden',
|
||||||
|
ConversationCleared: 'conversation.cleared',
|
||||||
|
ConversationHidden: 'conversation.hidden',
|
||||||
|
FoldersUpdate: 'folders.update',
|
||||||
|
|
||||||
// Bucket B
|
// Bucket B
|
||||||
TypingStart: 'typing.start',
|
TypingStart: 'typing.start',
|
||||||
|
|
@ -88,6 +97,13 @@ export interface ReadReceiptEvent {
|
||||||
seq: number;
|
seq: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A message was pinned/unpinned (shared state; clients refetch the pinned list).
|
||||||
|
export interface MessagePinEvent {
|
||||||
|
type: typeof EventType.MessagePin | typeof EventType.MessageUnpin;
|
||||||
|
conversationId: string;
|
||||||
|
messageId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TypingEvent {
|
export interface TypingEvent {
|
||||||
type: typeof EventType.TypingStart | typeof EventType.TypingStop;
|
type: typeof EventType.TypingStart | typeof EventType.TypingStop;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
|
|
@ -100,3 +116,29 @@ export interface ProfileUpdateEvent {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Delete for me" — only the acting user's devices hide the message.
|
||||||
|
export interface MessageHiddenEvent {
|
||||||
|
type: typeof EventType.MessageHidden;
|
||||||
|
conversationId: string;
|
||||||
|
messageId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Clear history" (for me) — drop everything up to and including upToSeq.
|
||||||
|
export interface ConversationClearedEvent {
|
||||||
|
type: typeof EventType.ConversationCleared;
|
||||||
|
conversationId: string;
|
||||||
|
upToSeq: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Delete chat" (for me) — remove from the list until new activity arrives.
|
||||||
|
export interface ConversationHiddenEvent {
|
||||||
|
type: typeof EventType.ConversationHidden;
|
||||||
|
conversationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Folders/pins changed on some device; payload is the fresh full snapshot.
|
||||||
|
export interface FoldersUpdateEvent {
|
||||||
|
type: typeof EventType.FoldersUpdate;
|
||||||
|
state: FoldersState;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ export const updateMeBodySchema = {
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
minProperties: 1,
|
minProperties: 1,
|
||||||
properties: {
|
properties: {
|
||||||
|
username: { type: 'string', pattern: USERNAME_PATTERN },
|
||||||
displayName: { type: 'string', minLength: 1, maxLength: 64 },
|
displayName: { type: 'string', minLength: 1, maxLength: 64 },
|
||||||
// `null` clears the value; a string sets it.
|
// `null` clears the value; a string sets it.
|
||||||
email: { type: ['string', 'null'], format: 'email', maxLength: 254 },
|
email: { type: ['string', 'null'], format: 'email', maxLength: 254 },
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,23 @@ export const createGroupBodySchema = {
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
// A channel: broadcast conversation; members are subscribers, only the owner
|
||||||
|
// and admins post. Description is optional (shown on the channel info page).
|
||||||
|
export const createChannelBodySchema = {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['title'],
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', minLength: 1, maxLength: 100 },
|
||||||
|
description: { type: 'string', maxLength: 500 },
|
||||||
|
members: {
|
||||||
|
type: 'array',
|
||||||
|
maxItems: 200,
|
||||||
|
items: { type: 'string', pattern: USERNAME_PATTERN },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const addMemberBodySchema = {
|
export const addMemberBodySchema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
|
|
@ -48,6 +65,21 @@ export const sendMessageBodySchema = {
|
||||||
// Object key returned by the media upload URL + its metadata.
|
// Object key returned by the media upload URL + its metadata.
|
||||||
mediaKey: { type: 'string', minLength: 1, maxLength: 255 },
|
mediaKey: { type: 'string', minLength: 1, maxLength: 255 },
|
||||||
media: mediaRefSchema,
|
media: mediaRefSchema,
|
||||||
|
// Reply target message id (must be in the same conversation).
|
||||||
|
replyToId: { type: 'string', format: 'uuid' },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Forward a message into the target conversation (the route's :id).
|
||||||
|
export const forwardMessageBodySchema = {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['sourceConversationId', 'messageId'],
|
||||||
|
properties: {
|
||||||
|
sourceConversationId: { type: 'string', format: 'uuid' },
|
||||||
|
messageId: { type: 'string', format: 'uuid' },
|
||||||
|
// When true the original author is hidden ("Hidden account") on the forward.
|
||||||
|
hideSender: { type: 'boolean' },
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
@ -60,8 +92,10 @@ export const addContactBodySchema = {
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export type ForwardMessageBody = FromSchema<typeof forwardMessageBodySchema>;
|
||||||
export type CreateDirectBody = FromSchema<typeof createDirectBodySchema>;
|
export type CreateDirectBody = FromSchema<typeof createDirectBodySchema>;
|
||||||
export type CreateGroupBody = FromSchema<typeof createGroupBodySchema>;
|
export type CreateGroupBody = FromSchema<typeof createGroupBodySchema>;
|
||||||
|
export type CreateChannelBody = FromSchema<typeof createChannelBodySchema>;
|
||||||
export type AddMemberBody = FromSchema<typeof addMemberBodySchema>;
|
export type AddMemberBody = FromSchema<typeof addMemberBodySchema>;
|
||||||
export type SendMessageBody = FromSchema<typeof sendMessageBodySchema>;
|
export type SendMessageBody = FromSchema<typeof sendMessageBodySchema>;
|
||||||
export type AddContactBody = FromSchema<typeof addContactBodySchema>;
|
export type AddContactBody = FromSchema<typeof addContactBodySchema>;
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,9 @@ export const authResultSchema = {
|
||||||
user: userSchema,
|
user: userSchema,
|
||||||
accessToken: { type: 'string' },
|
accessToken: { type: 'string' },
|
||||||
accessTokenExpiresIn: { type: 'integer' },
|
accessTokenExpiresIn: { type: 'integer' },
|
||||||
|
// Only present in token mode (mobile/native): the opaque refresh token, which
|
||||||
|
// the client stores in secure storage. Cookie mode (web) never returns it.
|
||||||
|
refreshToken: { type: 'string' },
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
@ -101,22 +104,44 @@ export const conversationSchema = {
|
||||||
'id',
|
'id',
|
||||||
'type',
|
'type',
|
||||||
'title',
|
'title',
|
||||||
|
'description',
|
||||||
|
'avatarUrl',
|
||||||
'peer',
|
'peer',
|
||||||
'createdBy',
|
'createdBy',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'lastMessageAt',
|
'lastMessageAt',
|
||||||
|
'lastMessage',
|
||||||
'unreadCount',
|
'unreadCount',
|
||||||
],
|
],
|
||||||
properties: {
|
properties: {
|
||||||
id: { type: 'string', format: 'uuid' },
|
id: { type: 'string', format: 'uuid' },
|
||||||
type: { type: 'string', enum: ['direct', 'group'] },
|
type: { type: 'string', enum: ['direct', 'group', 'channel'] },
|
||||||
title: { type: ['string', 'null'] },
|
title: { type: ['string', 'null'] },
|
||||||
|
description: { type: ['string', 'null'] },
|
||||||
|
avatarUrl: { type: ['string', 'null'] },
|
||||||
peer: {
|
peer: {
|
||||||
oneOf: [publicUserSchema, { type: 'null' }],
|
oneOf: [publicUserSchema, { type: 'null' }],
|
||||||
},
|
},
|
||||||
createdBy: { type: 'string', format: 'uuid' },
|
createdBy: { type: 'string', format: 'uuid' },
|
||||||
createdAt: { type: 'string', format: 'date-time' },
|
createdAt: { type: 'string', format: 'date-time' },
|
||||||
lastMessageAt: { type: 'string', format: 'date-time' },
|
lastMessageAt: { type: 'string', format: 'date-time' },
|
||||||
|
lastMessage: {
|
||||||
|
oneOf: [
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['senderId', 'senderName', 'content', 'mediaKind', 'deleted'],
|
||||||
|
properties: {
|
||||||
|
senderId: { type: 'string', format: 'uuid' },
|
||||||
|
senderName: { type: 'string' },
|
||||||
|
content: { type: 'string' },
|
||||||
|
mediaKind: { type: ['string', 'null'] },
|
||||||
|
deleted: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: 'null' },
|
||||||
|
],
|
||||||
|
},
|
||||||
unreadCount: { type: 'integer' },
|
unreadCount: { type: 'integer' },
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
@ -177,6 +202,29 @@ export const contactSchema = {
|
||||||
|
|
||||||
export const contactListSchema = { type: 'array', items: contactSchema } as const;
|
export const contactListSchema = { type: 'array', items: contactSchema } as const;
|
||||||
|
|
||||||
|
export const forwardOriginSchema = {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['name', 'user'],
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string' },
|
||||||
|
user: { oneOf: [publicUserSchema, { type: 'null' }] },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const replyPreviewSchema = {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['id', 'senderName', 'content', 'mediaKind', 'deleted'],
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', format: 'uuid' },
|
||||||
|
senderName: { type: 'string' },
|
||||||
|
content: { type: 'string' },
|
||||||
|
mediaKind: { type: ['string', 'null'] },
|
||||||
|
deleted: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const messageSchema = {
|
export const messageSchema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
|
|
@ -195,6 +243,9 @@ export const messageSchema = {
|
||||||
'deletedAt',
|
'deletedAt',
|
||||||
'reactions',
|
'reactions',
|
||||||
'media',
|
'media',
|
||||||
|
'replyTo',
|
||||||
|
'forwarded',
|
||||||
|
'forwardedFrom',
|
||||||
],
|
],
|
||||||
properties: {
|
properties: {
|
||||||
id: { type: 'string', format: 'uuid' },
|
id: { type: 'string', format: 'uuid' },
|
||||||
|
|
@ -211,6 +262,9 @@ export const messageSchema = {
|
||||||
deletedAt: { type: ['string', 'null'], format: 'date-time' },
|
deletedAt: { type: ['string', 'null'], format: 'date-time' },
|
||||||
reactions: { type: 'array', items: reactionSummarySchema },
|
reactions: { type: 'array', items: reactionSummarySchema },
|
||||||
media: { oneOf: [mediaRefSchema, { type: 'null' }] },
|
media: { oneOf: [mediaRefSchema, { type: 'null' }] },
|
||||||
|
replyTo: { oneOf: [replyPreviewSchema, { type: 'null' }] },
|
||||||
|
forwarded: { type: 'boolean' },
|
||||||
|
forwardedFrom: { oneOf: [forwardOriginSchema, { type: 'null' }] },
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
|
||||||
85
packages/core/src/schemas/folders.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import type { FromSchema } from 'json-schema-to-ts';
|
||||||
|
|
||||||
|
// Chat folders + pins. Response shape is always the full FoldersState snapshot.
|
||||||
|
|
||||||
|
export const chatFolderSchema = {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['id', 'title', 'position', 'chatIds'],
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
title: { type: 'string' },
|
||||||
|
position: { type: 'integer' },
|
||||||
|
chatIds: { type: 'array', items: { type: 'string' } },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const chatPinSchema = {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['conversationId', 'folderId', 'pinnedAt'],
|
||||||
|
properties: {
|
||||||
|
conversationId: { type: 'string' },
|
||||||
|
folderId: { type: ['string', 'null'] },
|
||||||
|
pinnedAt: { type: 'string' },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const foldersStateSchema = {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['folders', 'pins'],
|
||||||
|
properties: {
|
||||||
|
folders: { type: 'array', items: chatFolderSchema },
|
||||||
|
pins: { type: 'array', items: chatPinSchema },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const createFolderBodySchema = {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['title'],
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', minLength: 1, maxLength: 64 },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const updateFolderBodySchema = {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
minProperties: 1,
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', minLength: 1, maxLength: 64 },
|
||||||
|
position: { type: 'integer', minimum: 0 },
|
||||||
|
chatIds: { type: 'array', items: { type: 'string' }, maxItems: 200 },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Idempotent pin toggle within a scope (folderId null = the "All chats" tab).
|
||||||
|
export const setPinBodySchema = {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['conversationId', 'folderId', 'pinned'],
|
||||||
|
properties: {
|
||||||
|
conversationId: { type: 'string' },
|
||||||
|
folderId: { type: ['string', 'null'] },
|
||||||
|
pinned: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Shared-media listing for the profile panel, grouped Telegram-style.
|
||||||
|
export const mediaListQuerySchema = {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['tab'],
|
||||||
|
properties: {
|
||||||
|
tab: { type: 'string', enum: ['media', 'files', 'voice'] },
|
||||||
|
before: { type: 'integer', minimum: 1 },
|
||||||
|
limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type CreateFolderBody = FromSchema<typeof createFolderBodySchema>;
|
||||||
|
export type UpdateFolderBody = FromSchema<typeof updateFolderBodySchema>;
|
||||||
|
export type SetPinBody = FromSchema<typeof setPinBodySchema>;
|
||||||
|
export type MediaTab = FromSchema<typeof mediaListQuerySchema>['tab'];
|
||||||
|
|
@ -3,15 +3,19 @@ export type { RegisterBody, LoginBody, UpdateMeBody } from './auth';
|
||||||
export {
|
export {
|
||||||
createDirectBodySchema,
|
createDirectBodySchema,
|
||||||
createGroupBodySchema,
|
createGroupBodySchema,
|
||||||
|
createChannelBodySchema,
|
||||||
addMemberBodySchema,
|
addMemberBodySchema,
|
||||||
sendMessageBodySchema,
|
sendMessageBodySchema,
|
||||||
|
forwardMessageBodySchema,
|
||||||
addContactBodySchema,
|
addContactBodySchema,
|
||||||
} from './conversation';
|
} from './conversation';
|
||||||
export type {
|
export type {
|
||||||
CreateDirectBody,
|
CreateDirectBody,
|
||||||
CreateGroupBody,
|
CreateGroupBody,
|
||||||
|
CreateChannelBody,
|
||||||
AddMemberBody,
|
AddMemberBody,
|
||||||
SendMessageBody,
|
SendMessageBody,
|
||||||
|
ForwardMessageBody,
|
||||||
AddContactBody,
|
AddContactBody,
|
||||||
} from './conversation';
|
} from './conversation';
|
||||||
export { editMessageBodySchema, reactionBodySchema, readBodySchema } from './live';
|
export { editMessageBodySchema, reactionBodySchema, readBodySchema } from './live';
|
||||||
|
|
@ -26,6 +30,16 @@ export {
|
||||||
mediaUrlSchema,
|
mediaUrlSchema,
|
||||||
} from './media';
|
} from './media';
|
||||||
export type { MediaRef, MediaKind, UploadUrlBody, AvatarUploadBody, SetAvatarBody } from './media';
|
export type { MediaRef, MediaKind, UploadUrlBody, AvatarUploadBody, SetAvatarBody } from './media';
|
||||||
|
export {
|
||||||
|
chatFolderSchema,
|
||||||
|
chatPinSchema,
|
||||||
|
foldersStateSchema,
|
||||||
|
createFolderBodySchema,
|
||||||
|
updateFolderBodySchema,
|
||||||
|
setPinBodySchema,
|
||||||
|
mediaListQuerySchema,
|
||||||
|
} from './folders';
|
||||||
|
export type { CreateFolderBody, UpdateFolderBody, SetPinBody, MediaTab } from './folders';
|
||||||
export {
|
export {
|
||||||
registerDeviceBodySchema,
|
registerDeviceBodySchema,
|
||||||
updateSettingsBodySchema,
|
updateSettingsBodySchema,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
import type { FromSchema } from 'json-schema-to-ts';
|
import type { FromSchema } from 'json-schema-to-ts';
|
||||||
|
|
||||||
const MEDIA_KIND = { type: 'string', enum: ['image', 'video', 'voice', 'file'] } as const;
|
// `video_note` is a circular "video message" (Telegram-style), distinct from a
|
||||||
|
// regular `video` file so clients render it as a round player. `voice` covers
|
||||||
|
// both recorded voice notes and audio files (both use the waveform player).
|
||||||
|
const MEDIA_KIND = {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['image', 'video', 'video_note', 'voice', 'file'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
// Metadata for an attached media object (no object key — that stays server-side).
|
// Metadata for an attached media object (no object key — that stays server-side).
|
||||||
export const mediaRefSchema = {
|
export const mediaRefSchema = {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ export interface AuthResult {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
/** Access-token lifetime in seconds. */
|
/** Access-token lifetime in seconds. */
|
||||||
accessTokenExpiresIn: number;
|
accessTokenExpiresIn: number;
|
||||||
|
/** Present only in token mode (native clients); stored in secure storage. */
|
||||||
|
refreshToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// An active login session (one per device), from GET /auth/sessions.
|
// An active login session (one per device), from GET /auth/sessions.
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,37 @@
|
||||||
import type { PublicUser } from './user';
|
import type { PublicUser } from './user';
|
||||||
|
import type { MediaKind } from '../schemas/media';
|
||||||
|
|
||||||
export type ConversationType = 'direct' | 'group';
|
// 'channel' is a broadcast conversation: everyone reads, only the owner and
|
||||||
|
// admins (conversation_members.role) may post or edit.
|
||||||
|
export type ConversationType = 'direct' | 'group' | 'channel';
|
||||||
|
|
||||||
|
/** Chat-list preview of the newest message visible to the requesting user. */
|
||||||
|
export interface LastMessagePreview {
|
||||||
|
senderId: string;
|
||||||
|
senderName: string;
|
||||||
|
/** Text/caption; empty for a pure-media or deleted message. */
|
||||||
|
content: string;
|
||||||
|
/** Media kind when the message carries media, else null. */
|
||||||
|
mediaKind: MediaKind | null;
|
||||||
|
deleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Conversation {
|
export interface Conversation {
|
||||||
id: string;
|
id: string;
|
||||||
type: ConversationType;
|
type: ConversationType;
|
||||||
/** Title for a group; null for a direct conversation. */
|
/** Title for a group/channel; null for a direct conversation. */
|
||||||
title: string | null;
|
title: string | null;
|
||||||
/** The other participant for a direct conversation; null for a group. */
|
/** Optional channel description (null elsewhere). */
|
||||||
|
description: string | null;
|
||||||
|
/** Avatar URL: the peer's for a DM, the group/channel's own otherwise; null when unset. */
|
||||||
|
avatarUrl: string | null;
|
||||||
|
/** The other participant for a direct conversation; null for a group/channel. */
|
||||||
peer: PublicUser | null;
|
peer: PublicUser | null;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
lastMessageAt: string;
|
lastMessageAt: string;
|
||||||
|
/** Newest visible message for the chat-list row; null for an empty chat. */
|
||||||
|
lastMessage: LastMessagePreview | null;
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
24
packages/core/src/types/folders.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// Manual chat folders + pins (Telegram-style tabs). All per-user, server-stored
|
||||||
|
// so they sync across devices; every mutation returns (and broadcasts) the full
|
||||||
|
// FoldersState snapshot — it is small and keeps clients trivially consistent.
|
||||||
|
|
||||||
|
export interface ChatFolder {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
position: number;
|
||||||
|
/** Conversations manually added to this folder. */
|
||||||
|
chatIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pin is scoped: folderId null = pinned in the "All chats" tab. Pinning in
|
||||||
|
// one folder deliberately does not pin the chat anywhere else.
|
||||||
|
export interface ChatPin {
|
||||||
|
conversationId: string;
|
||||||
|
folderId: string | null;
|
||||||
|
pinnedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FoldersState {
|
||||||
|
folders: ChatFolder[];
|
||||||
|
pins: ChatPin[];
|
||||||
|
}
|
||||||
|
|
@ -3,10 +3,16 @@ export const CORE_VERSION = '0.1.0';
|
||||||
|
|
||||||
export type { User, PublicUser } from './user';
|
export type { User, PublicUser } from './user';
|
||||||
export type { AuthResult, Session, CentrifugoToken } from './auth';
|
export type { AuthResult, Session, CentrifugoToken } from './auth';
|
||||||
export type { Conversation, ConversationType, ConversationMember } from './conversation';
|
export type {
|
||||||
|
Conversation,
|
||||||
|
ConversationType,
|
||||||
|
ConversationMember,
|
||||||
|
LastMessagePreview,
|
||||||
|
} from './conversation';
|
||||||
export type { Contact } from './contact';
|
export type { Contact } from './contact';
|
||||||
export type { Message, ReactionSummary, MediaRef } from './message';
|
export type { Message, ReactionSummary, ReplyPreview, ForwardOrigin, MediaRef } from './message';
|
||||||
export type { Presence } from './presence';
|
export type { Presence } from './presence';
|
||||||
|
export type { ChatFolder, ChatPin, FoldersState } from './folders';
|
||||||
export type {
|
export type {
|
||||||
NotificationSettings,
|
NotificationSettings,
|
||||||
Device,
|
Device,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,26 @@ export interface ReactionSummary {
|
||||||
mine: boolean;
|
mine: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Where a forwarded message originally came from. `user` is set (and clickable
|
||||||
|
// → profile) when the origin is a person; null when it's a channel (show `name`
|
||||||
|
// only). The whole object is null on the message when the origin was hidden.
|
||||||
|
export interface ForwardOrigin {
|
||||||
|
name: string;
|
||||||
|
user: PublicUser | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A compact preview of the message being replied to (enough to render a quote).
|
||||||
|
export interface ReplyPreview {
|
||||||
|
id: string;
|
||||||
|
senderName: string;
|
||||||
|
/** Text preview; empty for media-only. */
|
||||||
|
content: string;
|
||||||
|
/** Media kind of the replied message, if any. */
|
||||||
|
mediaKind: MediaRef['kind'] | null;
|
||||||
|
/** True if the replied message has since been deleted. */
|
||||||
|
deleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// A chat message. `content` is a crypto-agnostic envelope: plaintext today,
|
// A chat message. `content` is a crypto-agnostic envelope: plaintext today,
|
||||||
// base64 ciphertext once E2EE lands. `seq` is the server-assigned monotonic order.
|
// base64 ciphertext once E2EE lands. `seq` is the server-assigned monotonic order.
|
||||||
export interface Message {
|
export interface Message {
|
||||||
|
|
@ -27,4 +47,11 @@ export interface Message {
|
||||||
deletedAt: string | null;
|
deletedAt: string | null;
|
||||||
reactions: ReactionSummary[];
|
reactions: ReactionSummary[];
|
||||||
media: MediaRef | null;
|
media: MediaRef | null;
|
||||||
|
/** The message this one replies to, or null. */
|
||||||
|
replyTo: ReplyPreview | null;
|
||||||
|
/** True if this message was forwarded. */
|
||||||
|
forwarded: boolean;
|
||||||
|
/** Origin when forwarded & not hidden; null when not forwarded OR hidden
|
||||||
|
* ("Hidden account"). Use `forwarded` to distinguish those two cases. */
|
||||||
|
forwardedFrom: ForwardOrigin | null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
6
packages/mobile/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||||
|
# The following patterns were generated by expo-cli
|
||||||
|
|
||||||
|
expo-env.d.ts
|
||||||
|
# @end expo-cli
|
||||||
67
packages/mobile/README.md
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# Zovi Mobile (Expo)
|
||||||
|
|
||||||
|
React Native + Expo (SDK 54) client for Altricade, consuming the shared
|
||||||
|
`@altricade/core` package as source. FSD layout: `app/` holds thin expo-router
|
||||||
|
route files only; all logic and UI live under `src/`.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- The backend + infra stack running (Postgres, Redis, MinIO, Centrifugo) — see
|
||||||
|
`infra/docker-compose.yml`. Start the Fastify backend so the API is on
|
||||||
|
`http://localhost:8080`.
|
||||||
|
- On a physical device the API/WS host must be reachable from the phone, so
|
||||||
|
point the client at your machine's LAN IP (see env below), not `localhost`.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install # from the repo root
|
||||||
|
|
||||||
|
# From packages/mobile:
|
||||||
|
pnpm start # Metro + dev menu
|
||||||
|
pnpm ios # build & run iOS (needs a dev build)
|
||||||
|
pnpm android # build & run Android (needs a dev build)
|
||||||
|
```
|
||||||
|
|
||||||
|
This app uses native modules (reanimated, gesture-handler, camera, audio,
|
||||||
|
video, secure-store, notifications), so it requires a **development build**
|
||||||
|
(`expo-dev-client`) — it will not run in Expo Go. Build one with EAS or
|
||||||
|
`expo run:ios` / `expo run:android`.
|
||||||
|
|
||||||
|
### Environment
|
||||||
|
|
||||||
|
Public config is read from `app.config.ts` `extra`, driven by `EXPO_PUBLIC_*`:
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
| ------------------------- | -------------------------------------------------- | -------------------------------- |
|
||||||
|
| `EXPO_PUBLIC_API_URL` | `http://localhost:8080/api` | REST base URL |
|
||||||
|
| `EXPO_PUBLIC_WS_URL` | `ws://localhost:8080/connection/websocket` | Centrifugo websocket |
|
||||||
|
| `EXPO_PUBLIC_PUSH_ENABLED`| unset (off) | Enable native push registration |
|
||||||
|
|
||||||
|
Example for a device on your LAN:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EXPO_PUBLIC_API_URL=http://192.168.1.20:8080/api \
|
||||||
|
EXPO_PUBLIC_WS_URL=ws://192.168.1.20:8080/connection/websocket \
|
||||||
|
pnpm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quality gates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm typecheck # tsc --noEmit (strict, no any/assertions/!)
|
||||||
|
pnpm lint # eslint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auth transport
|
||||||
|
|
||||||
|
Native uses token-mode auth: the refresh token is stored in `expo-secure-store`
|
||||||
|
(Keychain/Keystore) and sent in the request body with an `X-Auth-Mode: token`
|
||||||
|
header, instead of the web's httpOnly cookie. The access token stays in memory.
|
||||||
|
|
||||||
|
## Push notifications
|
||||||
|
|
||||||
|
Native push (FCM/APNs) needs a build with push credentials and cannot run in
|
||||||
|
Expo Go, so registration is gated behind `EXPO_PUBLIC_PUSH_ENABLED`. Tap-to-open
|
||||||
|
deep-linking (`src/services/usePush.ts`) is always wired, so once credentials
|
||||||
|
are configured, flip the flag and notifications route into the right chat.
|
||||||
46
packages/mobile/app.config.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import type { ExpoConfig } from 'expo/config';
|
||||||
|
|
||||||
|
// Public runtime config only — real secrets stay server-side. The API/WS base
|
||||||
|
// URLs point at the same gateway the web client uses; override per environment
|
||||||
|
// with EXPO_PUBLIC_API_URL / EXPO_PUBLIC_WS_URL (e.g. a LAN IP for a device).
|
||||||
|
const apiUrl = process.env['EXPO_PUBLIC_API_URL'] ?? 'http://localhost:8080/api';
|
||||||
|
const wsUrl = process.env['EXPO_PUBLIC_WS_URL'] ?? 'ws://localhost:8080/connection/websocket';
|
||||||
|
|
||||||
|
const config: ExpoConfig = {
|
||||||
|
name: 'Zovi',
|
||||||
|
slug: 'zovi',
|
||||||
|
scheme: 'zovi',
|
||||||
|
version: '0.1.0',
|
||||||
|
orientation: 'portrait',
|
||||||
|
userInterfaceStyle: 'automatic',
|
||||||
|
newArchEnabled: true,
|
||||||
|
ios: {
|
||||||
|
supportsTablet: true,
|
||||||
|
bundleIdentifier: 'com.altricade.messenger',
|
||||||
|
},
|
||||||
|
android: {
|
||||||
|
package: 'com.altricade.messenger',
|
||||||
|
edgeToEdgeEnabled: true,
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
'expo-router',
|
||||||
|
'expo-secure-store',
|
||||||
|
['expo-audio', { microphonePermission: 'Zovi uses the microphone to record voice messages.' }],
|
||||||
|
'expo-video',
|
||||||
|
['expo-camera', { cameraPermission: 'Zovi uses the camera for video messages.' }],
|
||||||
|
[
|
||||||
|
'expo-image-picker',
|
||||||
|
{ photosPermission: 'Zovi accesses your photos to share images and videos.' },
|
||||||
|
],
|
||||||
|
'expo-notifications',
|
||||||
|
],
|
||||||
|
experiments: {
|
||||||
|
typedRoutes: true,
|
||||||
|
},
|
||||||
|
extra: {
|
||||||
|
apiUrl,
|
||||||
|
wsUrl,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
46
packages/mobile/app/(app)/(tabs)/_layout.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { Tabs } from 'expo-router';
|
||||||
|
import { Icon } from '@/components';
|
||||||
|
import type { IconName } from '@/components';
|
||||||
|
import { useTheme } from '@/theme';
|
||||||
|
|
||||||
|
// Open on Chats, not the leftmost (Contacts) tab.
|
||||||
|
export const unstable_settings = {
|
||||||
|
initialRouteName: 'index',
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabIcon =
|
||||||
|
(name: IconName) =>
|
||||||
|
({ color, size }: { color: string; size: number }): ReactElement => (
|
||||||
|
<Icon name={name} size={size} color={color} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// Persistent bottom navigation for the authenticated area: Contacts · Chats ·
|
||||||
|
// Settings. Chat/profile/group and modals live in the parent stack so they
|
||||||
|
// cover the tab bar when opened.
|
||||||
|
export default function TabsLayout(): ReactElement {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
tabBarActiveTintColor: colors.accent,
|
||||||
|
tabBarInactiveTintColor: colors.textMuted,
|
||||||
|
tabBarStyle: {
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="contacts"
|
||||||
|
options={{ title: 'Contacts', tabBarIcon: tabIcon('user') }}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen name="index" options={{ title: 'Chats', tabBarIcon: tabIcon('chats') }} />
|
||||||
|
<Tabs.Screen
|
||||||
|
name="settings"
|
||||||
|
options={{ title: 'Settings', tabBarIcon: tabIcon('settings') }}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
packages/mobile/app/(app)/(tabs)/contacts.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { ContactsScreen } from '@/features/contacts';
|
||||||
|
|
||||||
|
export default function ContactsRoute(): ReactElement {
|
||||||
|
return <ContactsScreen />;
|
||||||
|
}
|
||||||
6
packages/mobile/app/(app)/(tabs)/index.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { ChatsScreen } from '@/features/conversations';
|
||||||
|
|
||||||
|
export default function ChatsRoute(): ReactElement {
|
||||||
|
return <ChatsScreen />;
|
||||||
|
}
|
||||||
6
packages/mobile/app/(app)/(tabs)/settings.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { SettingsScreen } from '@/features/settings';
|
||||||
|
|
||||||
|
export default function SettingsRoute(): ReactElement {
|
||||||
|
return <SettingsScreen />;
|
||||||
|
}
|
||||||
29
packages/mobile/app/(app)/_layout.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { RealtimeProvider } from '@/ws';
|
||||||
|
import { useTheme } from '@/theme';
|
||||||
|
import { usePush } from '@/services/usePush';
|
||||||
|
|
||||||
|
// Authenticated area: everything here has a live realtime connection.
|
||||||
|
export default function AppLayout(): ReactElement {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
usePush();
|
||||||
|
return (
|
||||||
|
<RealtimeProvider>
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: colors.background },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen name="(tabs)" />
|
||||||
|
<Stack.Screen name="chat/[id]" />
|
||||||
|
<Stack.Screen name="profile/[username]" options={{ presentation: 'card' }} />
|
||||||
|
<Stack.Screen name="group/[id]" options={{ presentation: 'card' }} />
|
||||||
|
<Stack.Screen name="new/group" options={{ presentation: 'modal' }} />
|
||||||
|
<Stack.Screen name="new/channel" options={{ presentation: 'modal' }} />
|
||||||
|
<Stack.Screen name="forward" options={{ presentation: 'modal' }} />
|
||||||
|
</Stack>
|
||||||
|
</RealtimeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
packages/mobile/app/(app)/chat/[id].tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
|
import { ChatScreen } from '@/features/messaging';
|
||||||
|
|
||||||
|
export default function ChatRoute(): ReactElement | null {
|
||||||
|
const params = useLocalSearchParams<{ id: string }>();
|
||||||
|
const id = typeof params.id === 'string' ? params.id : null;
|
||||||
|
if (id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return <ChatScreen conversationId={id} />;
|
||||||
|
}
|
||||||
14
packages/mobile/app/(app)/forward.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
|
import { ForwardScreen } from '@/features/messaging';
|
||||||
|
|
||||||
|
export default function ForwardRoute(): ReactElement | null {
|
||||||
|
const params = useLocalSearchParams<{ ids: string; from: string }>();
|
||||||
|
const idsParam = typeof params.ids === 'string' ? params.ids : '';
|
||||||
|
const from = typeof params.from === 'string' ? params.from : null;
|
||||||
|
const messageIds = idsParam.split(',').filter((id) => id.length > 0);
|
||||||
|
if (from === null || messageIds.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return <ForwardScreen messageIds={messageIds} fromConversationId={from} />;
|
||||||
|
}
|
||||||
12
packages/mobile/app/(app)/group/[id].tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
|
import { GroupInfoScreen } from '@/features/profile';
|
||||||
|
|
||||||
|
export default function GroupInfoRoute(): ReactElement | null {
|
||||||
|
const params = useLocalSearchParams<{ id: string }>();
|
||||||
|
const id = typeof params.id === 'string' ? params.id : null;
|
||||||
|
if (id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return <GroupInfoScreen conversationId={id} />;
|
||||||
|
}
|
||||||
6
packages/mobile/app/(app)/new/channel.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { NewChatWizard } from '@/features/conversations';
|
||||||
|
|
||||||
|
export default function NewChannelRoute(): ReactElement {
|
||||||
|
return <NewChatWizard mode="channel" />;
|
||||||
|
}
|
||||||
6
packages/mobile/app/(app)/new/group.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { NewChatWizard } from '@/features/conversations';
|
||||||
|
|
||||||
|
export default function NewGroupRoute(): ReactElement {
|
||||||
|
return <NewChatWizard mode="group" />;
|
||||||
|
}
|
||||||
12
packages/mobile/app/(app)/profile/[username].tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
|
import { ProfileScreen } from '@/features/profile';
|
||||||
|
|
||||||
|
export default function ProfileRoute(): ReactElement | null {
|
||||||
|
const params = useLocalSearchParams<{ username: string }>();
|
||||||
|
const username = typeof params.username === 'string' ? params.username : null;
|
||||||
|
if (username === null || username.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return <ProfileScreen username={username} />;
|
||||||
|
}
|
||||||
6
packages/mobile/app/(auth)/_layout.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function AuthLayout(): ReactElement {
|
||||||
|
return <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
}
|
||||||
6
packages/mobile/app/(auth)/sign-in.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { SignInScreen } from '@/features/auth';
|
||||||
|
|
||||||
|
export default function SignIn(): ReactElement {
|
||||||
|
return <SignInScreen />;
|
||||||
|
}
|
||||||
65
packages/mobile/app/_layout.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import 'react-native-gesture-handler';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { ActivityIndicator, View } from 'react-native';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
|
import { KeyboardProvider } from 'react-native-keyboard-controller';
|
||||||
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
|
import { setAudioModeAsync } from 'expo-audio';
|
||||||
|
import { ThemeProvider, useTheme } from '@/theme';
|
||||||
|
import { useSession } from '@/stores/session';
|
||||||
|
import { ErrorBoundary } from '@/components';
|
||||||
|
|
||||||
|
// Voice notes must play even with the iPhone mute switch on (Telegram
|
||||||
|
// behavior) — without this, playback "works" but is silent on most devices.
|
||||||
|
void setAudioModeAsync({ playsInSilentMode: true });
|
||||||
|
|
||||||
|
const RootNavigator = (): ReactElement => {
|
||||||
|
const { colors, name } = useTheme();
|
||||||
|
const status = useSession((s) => s.status);
|
||||||
|
const bootstrap = useSession((s) => s.bootstrap);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void bootstrap();
|
||||||
|
}, [bootstrap]);
|
||||||
|
|
||||||
|
if (status === 'loading') {
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.background }}>
|
||||||
|
<ActivityIndicator color={colors.accent} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StatusBar style={name === 'dark' ? 'light' : 'dark'} />
|
||||||
|
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.background } }}>
|
||||||
|
<Stack.Protected guard={status === 'authenticated'}>
|
||||||
|
<Stack.Screen name="(app)" />
|
||||||
|
</Stack.Protected>
|
||||||
|
<Stack.Protected guard={status !== 'authenticated'}>
|
||||||
|
<Stack.Screen name="(auth)" />
|
||||||
|
</Stack.Protected>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout(): ReactElement {
|
||||||
|
return (
|
||||||
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<KeyboardProvider>
|
||||||
|
<ThemeProvider>
|
||||||
|
<RootNavigator />
|
||||||
|
</ThemeProvider>
|
||||||
|
</KeyboardProvider>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
packages/mobile/babel.config.js
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
// Expo + Reanimated. The worklets plugin (Reanimated 4) MUST be listed last.
|
||||||
|
module.exports = function babel(api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: ['babel-preset-expo'],
|
||||||
|
plugins: ['react-native-worklets/plugin'],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -1,3 +1,33 @@
|
||||||
import { base } from '../../eslint.config.mjs';
|
import { base } from '../../eslint.config.mjs';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import globals from 'globals';
|
||||||
|
|
||||||
export default base;
|
// Mobile (React Native + Expo). Same strict base as the rest of the repo, plus
|
||||||
|
// react-hooks rules and native globals. Route files live in app/ (thin), logic
|
||||||
|
// in src/ following FSD; the type-safety rules (no any/assertions/!) still apply.
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
'.expo/**',
|
||||||
|
'android/**',
|
||||||
|
'ios/**',
|
||||||
|
'expo-env.d.ts',
|
||||||
|
'metro.config.js',
|
||||||
|
'babel.config.js',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
...base,
|
||||||
|
{
|
||||||
|
files: ['app/**/*.{ts,tsx}', 'src/**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.browser },
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
|
'react-hooks/exhaustive-deps': 'warn',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
|
||||||
3
packages/mobile/expo-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
/// <reference types="expo/types" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited and should be in your git ignore
|
||||||
30
packages/mobile/ios/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# OSX
|
||||||
|
#
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Xcode
|
||||||
|
#
|
||||||
|
build/
|
||||||
|
*.pbxuser
|
||||||
|
!default.pbxuser
|
||||||
|
*.mode1v3
|
||||||
|
!default.mode1v3
|
||||||
|
*.mode2v3
|
||||||
|
!default.mode2v3
|
||||||
|
*.perspectivev3
|
||||||
|
!default.perspectivev3
|
||||||
|
xcuserdata
|
||||||
|
*.xccheckout
|
||||||
|
*.moved-aside
|
||||||
|
DerivedData
|
||||||
|
*.hmap
|
||||||
|
*.ipa
|
||||||
|
*.xcuserstate
|
||||||
|
project.xcworkspace
|
||||||
|
.xcode.env.local
|
||||||
|
|
||||||
|
# Bundle artifacts
|
||||||
|
*.jsbundle
|
||||||
|
|
||||||
|
# CocoaPods
|
||||||
|
/Pods/
|
||||||
11
packages/mobile/ios/.xcode.env
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# This `.xcode.env` file is versioned and is used to source the environment
|
||||||
|
# used when running script phases inside Xcode.
|
||||||
|
# To customize your local environment, you can create an `.xcode.env.local`
|
||||||
|
# file that is not versioned.
|
||||||
|
|
||||||
|
# NODE_BINARY variable contains the PATH to the node executable.
|
||||||
|
#
|
||||||
|
# Customize the NODE_BINARY variable here.
|
||||||
|
# For example, to use nvm with brew, add the following line
|
||||||
|
# . "$(brew --prefix nvm)/nvm.sh" --no-use
|
||||||
|
export NODE_BINARY=$(command -v node)
|
||||||
63
packages/mobile/ios/Podfile
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
require File.join(File.dirname(`node --print "require.resolve('expo/package.json')"`), "scripts/autolinking")
|
||||||
|
require File.join(File.dirname(`node --print "require.resolve('react-native/package.json')"`), "scripts/react_native_pods")
|
||||||
|
|
||||||
|
require 'json'
|
||||||
|
podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {}
|
||||||
|
|
||||||
|
def ccache_enabled?(podfile_properties)
|
||||||
|
# Environment variable takes precedence
|
||||||
|
return ENV['USE_CCACHE'] == '1' if ENV['USE_CCACHE']
|
||||||
|
|
||||||
|
# Fall back to Podfile properties
|
||||||
|
podfile_properties['apple.ccacheEnabled'] == 'true'
|
||||||
|
end
|
||||||
|
|
||||||
|
ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if podfile_properties['newArchEnabled'] == 'false'
|
||||||
|
ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] ||= podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR']
|
||||||
|
ENV['RCT_USE_RN_DEP'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' && podfile_properties['newArchEnabled'] != 'false'
|
||||||
|
ENV['RCT_USE_PREBUILT_RNCORE'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' && podfile_properties['newArchEnabled'] != 'false'
|
||||||
|
platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1'
|
||||||
|
|
||||||
|
prepare_react_native_project!
|
||||||
|
|
||||||
|
target 'Zovi' do
|
||||||
|
use_expo_modules!
|
||||||
|
|
||||||
|
if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] == '1'
|
||||||
|
config_command = ['node', '-e', "process.argv=['', '', 'config'];require('@react-native-community/cli').run()"];
|
||||||
|
else
|
||||||
|
config_command = [
|
||||||
|
'node',
|
||||||
|
'--no-warnings',
|
||||||
|
'--eval',
|
||||||
|
'require(\'expo/bin/autolinking\')',
|
||||||
|
'expo-modules-autolinking',
|
||||||
|
'react-native-config',
|
||||||
|
'--json',
|
||||||
|
'--platform',
|
||||||
|
'ios'
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
config = use_native_modules!(config_command)
|
||||||
|
|
||||||
|
use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks']
|
||||||
|
use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS']
|
||||||
|
|
||||||
|
use_react_native!(
|
||||||
|
:path => config[:reactNativePath],
|
||||||
|
:hermes_enabled => podfile_properties['expo.jsEngine'] == nil || podfile_properties['expo.jsEngine'] == 'hermes',
|
||||||
|
# An absolute path to your application root.
|
||||||
|
:app_path => "#{Pod::Config.instance.installation_root}/..",
|
||||||
|
:privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false',
|
||||||
|
)
|
||||||
|
|
||||||
|
post_install do |installer|
|
||||||
|
react_native_post_install(
|
||||||
|
installer,
|
||||||
|
config[:reactNativePath],
|
||||||
|
:mac_catalyst_enabled => false,
|
||||||
|
:ccache_enabled => ccache_enabled?(podfile_properties),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
2597
packages/mobile/ios/Podfile.lock
Normal file
5
packages/mobile/ios/Podfile.properties.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"expo.jsEngine": "hermes",
|
||||||
|
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
|
||||||
|
"newArchEnabled": "true"
|
||||||
|
}
|
||||||
560
packages/mobile/ios/Zovi.xcodeproj/project.pbxproj
Normal file
|
|
@ -0,0 +1,560 @@
|
||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 54;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||||
|
2600D210A644F3099EBCAC60 /* libPods-Zovi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 2F35A3DE0EDD554ECC9665C9 /* libPods-Zovi.a */; };
|
||||||
|
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
||||||
|
B1BC7EA401DEA33ACBA14386 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 5E79EB555E8560CE37666A91 /* PrivacyInfo.xcprivacy */; };
|
||||||
|
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
||||||
|
E4F7C222FFFCC78DE13D287F /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5206ED93486F6EE25D62DF8 /* ExpoModulesProvider.swift */; };
|
||||||
|
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
01B1386BDA70C35F3BB3A395 /* Pods-Zovi.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Zovi.debug.xcconfig"; path = "Target Support Files/Pods-Zovi/Pods-Zovi.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
13B07F961A680F5B00A75B9A /* Zovi.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Zovi.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Zovi/Images.xcassets; sourceTree = "<group>"; };
|
||||||
|
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Zovi/Info.plist; sourceTree = "<group>"; };
|
||||||
|
2F35A3DE0EDD554ECC9665C9 /* libPods-Zovi.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Zovi.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
5E79EB555E8560CE37666A91 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Zovi/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||||
|
A5206ED93486F6EE25D62DF8 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Zovi/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
||||||
|
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Zovi/SplashScreen.storyboard; sourceTree = "<group>"; };
|
||||||
|
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
|
||||||
|
DE9441A6824A495DF6F5687D /* Pods-Zovi.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Zovi.release.xcconfig"; path = "Target Support Files/Pods-Zovi/Pods-Zovi.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
||||||
|
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Zovi/AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
F11748442D0722820044C1D9 /* Zovi-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "Zovi-Bridging-Header.h"; path = "Zovi/Zovi-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
13B07F8C1A680F5B00A75B9A /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
2600D210A644F3099EBCAC60 /* libPods-Zovi.a in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
13B07FAE1A68108700A75B9A /* Zovi */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
|
||||||
|
F11748442D0722820044C1D9 /* Zovi-Bridging-Header.h */,
|
||||||
|
BB2F792B24A3F905000567C9 /* Supporting */,
|
||||||
|
13B07FB51A68108700A75B9A /* Images.xcassets */,
|
||||||
|
13B07FB61A68108700A75B9A /* Info.plist */,
|
||||||
|
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
|
||||||
|
5E79EB555E8560CE37666A91 /* PrivacyInfo.xcprivacy */,
|
||||||
|
);
|
||||||
|
name = Zovi;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
2D16E6871FA4F8E400B85C8A /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
|
||||||
|
2F35A3DE0EDD554ECC9665C9 /* libPods-Zovi.a */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
68802507543A031D2F8E62A0 /* Pods */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
01B1386BDA70C35F3BB3A395 /* Pods-Zovi.debug.xcconfig */,
|
||||||
|
DE9441A6824A495DF6F5687D /* Pods-Zovi.release.xcconfig */,
|
||||||
|
);
|
||||||
|
name = Pods;
|
||||||
|
path = Pods;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
832341AE1AAA6A7D00B99B32 /* Libraries */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
);
|
||||||
|
name = Libraries;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
83CBB9F61A601CBA00E9B192 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
13B07FAE1A68108700A75B9A /* Zovi */,
|
||||||
|
832341AE1AAA6A7D00B99B32 /* Libraries */,
|
||||||
|
83CBBA001A601CBA00E9B192 /* Products */,
|
||||||
|
2D16E6871FA4F8E400B85C8A /* Frameworks */,
|
||||||
|
68802507543A031D2F8E62A0 /* Pods */,
|
||||||
|
A06D5BE166B78DD90D60B1AC /* ExpoModulesProviders */,
|
||||||
|
);
|
||||||
|
indentWidth = 2;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
tabWidth = 2;
|
||||||
|
usesTabs = 0;
|
||||||
|
};
|
||||||
|
83CBBA001A601CBA00E9B192 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
13B07F961A680F5B00A75B9A /* Zovi.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A06D5BE166B78DD90D60B1AC /* ExpoModulesProviders */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C497067B79F68A595CE42947 /* Zovi */,
|
||||||
|
);
|
||||||
|
name = ExpoModulesProviders;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
BB2F792B24A3F905000567C9 /* Supporting */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
BB2F792C24A3F905000567C9 /* Expo.plist */,
|
||||||
|
);
|
||||||
|
name = Supporting;
|
||||||
|
path = Zovi/Supporting;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C497067B79F68A595CE42947 /* Zovi */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A5206ED93486F6EE25D62DF8 /* ExpoModulesProvider.swift */,
|
||||||
|
);
|
||||||
|
name = Zovi;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
13B07F861A680F5B00A75B9A /* Zovi */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Zovi" */;
|
||||||
|
buildPhases = (
|
||||||
|
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
|
||||||
|
C7038DBFE2B5A25EBB692046 /* [Expo] Configure project */,
|
||||||
|
13B07F871A680F5B00A75B9A /* Sources */,
|
||||||
|
13B07F8C1A680F5B00A75B9A /* Frameworks */,
|
||||||
|
13B07F8E1A680F5B00A75B9A /* Resources */,
|
||||||
|
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
|
||||||
|
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
|
||||||
|
B720D6E7DDBC52B4CBBC0DB6 /* [CP] Embed Pods Frameworks */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = Zovi;
|
||||||
|
productName = Zovi;
|
||||||
|
productReference = 13B07F961A680F5B00A75B9A /* Zovi.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
83CBB9F71A601CBA00E9B192 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
LastUpgradeCheck = 1130;
|
||||||
|
TargetAttributes = {
|
||||||
|
13B07F861A680F5B00A75B9A = {
|
||||||
|
DevelopmentTeam = 75MMB3DXA8;
|
||||||
|
LastSwiftMigration = 1250;
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Zovi" */;
|
||||||
|
compatibilityVersion = "Xcode 3.2";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = 83CBB9F61A601CBA00E9B192;
|
||||||
|
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
13B07F861A680F5B00A75B9A /* Zovi */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
13B07F8E1A680F5B00A75B9A /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
|
||||||
|
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
||||||
|
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
||||||
|
B1BC7EA401DEA33ACBA14386 /* PrivacyInfo.xcprivacy in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
|
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"$(SRCROOT)/.xcode.env",
|
||||||
|
"$(SRCROOT)/.xcode.env.local",
|
||||||
|
);
|
||||||
|
name = "Bundle React Native code and images";
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n";
|
||||||
|
};
|
||||||
|
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||||
|
"${PODS_ROOT}/Manifest.lock",
|
||||||
|
);
|
||||||
|
name = "[CP] Check Pods Manifest.lock";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
"$(DERIVED_FILE_DIR)/Pods-Zovi-checkManifestLockResult.txt",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
|
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-Zovi/Pods-Zovi-resources.sh",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/EXApplication/ExpoApplication_privacy.bundle",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle",
|
||||||
|
);
|
||||||
|
name = "[CP] Copy Pods Resources";
|
||||||
|
outputPaths = (
|
||||||
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoApplication_privacy.bundle",
|
||||||
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
|
||||||
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
|
||||||
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle",
|
||||||
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
|
||||||
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle",
|
||||||
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
|
||||||
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
|
||||||
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
|
||||||
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
|
||||||
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Zovi/Pods-Zovi-resources.sh\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
|
B720D6E7DDBC52B4CBBC0DB6 /* [CP] Embed Pods Frameworks */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-Zovi/Pods-Zovi-frameworks.sh",
|
||||||
|
"${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React",
|
||||||
|
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies",
|
||||||
|
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
|
||||||
|
);
|
||||||
|
name = "[CP] Embed Pods Frameworks";
|
||||||
|
outputPaths = (
|
||||||
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework",
|
||||||
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework",
|
||||||
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Zovi/Pods-Zovi-frameworks.sh\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
|
C7038DBFE2B5A25EBB692046 /* [Expo] Configure project */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"$(SRCROOT)/.xcode.env",
|
||||||
|
"$(SRCROOT)/.xcode.env.local",
|
||||||
|
"$(SRCROOT)/Zovi/Zovi.entitlements",
|
||||||
|
"$(SRCROOT)/Pods/Target Support Files/Pods-Zovi/expo-configure-project.sh",
|
||||||
|
);
|
||||||
|
name = "[Expo] Configure project";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
"$(SRCROOT)/Pods/Target Support Files/Pods-Zovi/ExpoModulesProvider.swift",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Zovi/expo-configure-project.sh\"\n";
|
||||||
|
};
|
||||||
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
13B07F871A680F5B00A75B9A /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
|
||||||
|
E4F7C222FFFCC78DE13D287F /* ExpoModulesProvider.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
13B07F941A680F5B00A75B9A /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 01B1386BDA70C35F3BB3A395 /* Pods-Zovi.debug.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Zovi/Zovi.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 75MMB3DXA8;
|
||||||
|
ENABLE_BITCODE = NO;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"FB_SONARKIT_ENABLED=1",
|
||||||
|
);
|
||||||
|
INFOPLIST_FILE = Zovi/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
OTHER_LDFLAGS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"-ObjC",
|
||||||
|
"-lc++",
|
||||||
|
);
|
||||||
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.altricade.messenger;
|
||||||
|
PRODUCT_NAME = Zovi;
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "Zovi/Zovi-Bridging-Header.h";
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
13B07F951A680F5B00A75B9A /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = DE9441A6824A495DF6F5687D /* Pods-Zovi.release.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Zovi/Zovi.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 75MMB3DXA8;
|
||||||
|
INFOPLIST_FILE = Zovi/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
OTHER_LDFLAGS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"-ObjC",
|
||||||
|
"-lc++",
|
||||||
|
);
|
||||||
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.altricade.messenger;
|
||||||
|
PRODUCT_NAME = Zovi;
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "Zovi/Zovi-Bridging-Header.h";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
83CBBA201A601CBA00E9B192 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
GCC_SYMBOLS_PRIVATE_EXTERN = NO;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
/usr/lib/swift,
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||||
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
||||||
|
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||||
|
USE_HERMES = true;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
83CBBA211A601CBA00E9B192 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
|
COPY_PHASE_STRIP = YES;
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
/usr/lib/swift,
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||||
|
USE_HERMES = true;
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Zovi" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
13B07F941A680F5B00A75B9A /* Debug */,
|
||||||
|
13B07F951A680F5B00A75B9A /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Zovi" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
83CBBA201A601CBA00E9B192 /* Debug */,
|
||||||
|
83CBBA211A601CBA00E9B192 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1130"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||||
|
BuildableName = "Zovi.app"
|
||||||
|
BlueprintName = "Zovi"
|
||||||
|
ReferencedContainer = "container:Zovi.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "00E356ED1AD99517003FC87E"
|
||||||
|
BuildableName = "ZoviTests.xctest"
|
||||||
|
BlueprintName = "ZoviTests"
|
||||||
|
ReferencedContainer = "container:Zovi.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||||
|
BuildableName = "Zovi.app"
|
||||||
|
BlueprintName = "Zovi"
|
||||||
|
ReferencedContainer = "container:Zovi.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||||
|
BuildableName = "Zovi.app"
|
||||||
|
BlueprintName = "Zovi"
|
||||||
|
ReferencedContainer = "container:Zovi.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
10
packages/mobile/ios/Zovi.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "group:Zovi.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
<FileRef
|
||||||
|
location = "group:Pods/Pods.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||