redesign
|
|
@ -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.
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
@ -67,7 +67,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 |
|
|
@ -55,7 +55,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",
|
||||||
|
|
|
||||||
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;
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -66,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 {
|
||||||
|
|
@ -163,4 +178,5 @@ export interface Database {
|
||||||
chat_folders: ChatFoldersTable;
|
chat_folders: ChatFoldersTable;
|
||||||
chat_folder_items: ChatFolderItemsTable;
|
chat_folder_items: ChatFolderItemsTable;
|
||||||
chat_pins: ChatPinsTable;
|
chat_pins: ChatPinsTable;
|
||||||
|
message_pins: MessagePinsTable;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,11 @@ export const toConversation = (
|
||||||
unreadCount = 0,
|
unreadCount = 0,
|
||||||
): 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(),
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,12 @@ 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<ConversationRow[]>;
|
||||||
|
|
@ -39,6 +45,7 @@ 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. */
|
/** "Clear history" for me: hide everything up to the current max seq; resets unread. Returns that seq. */
|
||||||
clearForUser(conversationId: string, userId: string): Promise<number>;
|
clearForUser(conversationId: string, userId: string): Promise<number>;
|
||||||
/** "Delete chat" for me: clear + drop from the list until new activity arrives. Returns cleared seq. */
|
/** "Delete chat" for me: clear + drop from the list until new activity arrives. Returns cleared seq. */
|
||||||
|
|
@ -118,6 +125,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
|
||||||
|
|
@ -293,6 +324,14 @@ export const createConversationsRepository = (db: Kysely<Database>): Conversatio
|
||||||
.execute();
|
.execute();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setImage: (conversationId, imageUrl) =>
|
||||||
|
db
|
||||||
|
.updateTable('conversations')
|
||||||
|
.set({ image_ref: imageUrl })
|
||||||
|
.where('id', '=', conversationId)
|
||||||
|
.returningAll()
|
||||||
|
.executeTakeFirst(),
|
||||||
|
|
||||||
clearForUser,
|
clearForUser,
|
||||||
|
|
||||||
hideForUser: async (conversationId, userId) => {
|
hideForUser: async (conversationId, userId) => {
|
||||||
|
|
|
||||||
|
|
@ -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,35 @@ 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 } }>(
|
app.post<{ Params: { id: string } }>(
|
||||||
'/conversations/:id/clear',
|
'/conversations/:id/clear',
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,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[]>;
|
||||||
|
|
@ -38,6 +44,7 @@ export interface ConversationsService {
|
||||||
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>;
|
clear(conversationId: string, userId: string): Promise<void>;
|
||||||
hide(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 = (
|
||||||
|
|
@ -62,6 +69,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,
|
||||||
|
|
@ -96,13 +115,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,
|
||||||
|
|
@ -113,6 +126,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);
|
||||||
|
|
@ -206,6 +231,32 @@ export const createConversationsService = (
|
||||||
};
|
};
|
||||||
await publish(userChannel(userId), event);
|
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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -48,6 +58,20 @@ export interface MessagesRepository {
|
||||||
beforeSeq: number | null,
|
beforeSeq: number | null,
|
||||||
limit: number,
|
limit: number,
|
||||||
): Promise<MessageWithSenderRow[]>;
|
): 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. */
|
/** Media messages as seen by `userId` (profile panel tabs), newest first. */
|
||||||
listMedia(
|
listMedia(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
|
|
@ -70,6 +94,9 @@ export interface MessagesRepository {
|
||||||
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 => {
|
||||||
|
|
@ -77,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',
|
||||||
|
|
@ -94,6 +124,13 @@ 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
|
// The per-user view: joins the member row to honor the clear mark and
|
||||||
|
|
@ -131,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(),
|
||||||
|
|
@ -159,6 +199,30 @@ export const createMessagesRepository = (db: Kysely<Database>): MessagesReposito
|
||||||
return query.orderBy('messages.seq', 'desc').limit(limit).execute();
|
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) => {
|
listMedia: (conversationId, userId, kinds, beforeSeq, limit) => {
|
||||||
let query = visibleTo(userId)
|
let query = visibleTo(userId)
|
||||||
.where('messages.conversation_id', '=', conversationId)
|
.where('messages.conversation_id', '=', conversationId)
|
||||||
|
|
@ -257,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,13 +3,20 @@ import {
|
||||||
sendMessageBodySchema,
|
sendMessageBodySchema,
|
||||||
editMessageBodySchema,
|
editMessageBodySchema,
|
||||||
reactionBodySchema,
|
reactionBodySchema,
|
||||||
|
forwardMessageBodySchema,
|
||||||
messageSchema,
|
messageSchema,
|
||||||
messageListSchema,
|
messageListSchema,
|
||||||
mediaUrlSchema,
|
mediaUrlSchema,
|
||||||
mediaListQuerySchema,
|
mediaListQuerySchema,
|
||||||
errorSchema,
|
errorSchema,
|
||||||
} from '@altricade/core';
|
} from '@altricade/core';
|
||||||
import type { SendMessageBody, EditMessageBody, ReactionBody, MediaTab } from '@altricade/core';
|
import type {
|
||||||
|
SendMessageBody,
|
||||||
|
EditMessageBody,
|
||||||
|
ReactionBody,
|
||||||
|
ForwardMessageBody,
|
||||||
|
MediaTab,
|
||||||
|
} from '@altricade/core';
|
||||||
|
|
||||||
const bearerAuth = [{ bearerAuth: [] }];
|
const bearerAuth = [{ bearerAuth: [] }];
|
||||||
const idParamsSchema = {
|
const idParamsSchema = {
|
||||||
|
|
@ -38,17 +45,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,
|
||||||
|
|
@ -59,6 +75,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,
|
||||||
|
|
@ -70,6 +96,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',
|
||||||
{
|
{
|
||||||
|
|
@ -157,6 +210,92 @@ export const messagesRoutes = (app: FastifyInstance): Promise<void> => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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 } }>(
|
app.get<{ Params: { id: string }; Querystring: { tab: MediaTab; before?: number; limit: number } }>(
|
||||||
'/conversations/:id/media',
|
'/conversations/:id/media',
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { EventType, userChannel } from '@altricade/core';
|
import { EventType, userChannel } from '@altricade/core';
|
||||||
import type {
|
import type {
|
||||||
Message,
|
Message,
|
||||||
|
|
@ -5,9 +6,11 @@ import type {
|
||||||
MessageEditEvent,
|
MessageEditEvent,
|
||||||
MessageDeleteEvent,
|
MessageDeleteEvent,
|
||||||
MessageHiddenEvent,
|
MessageHiddenEvent,
|
||||||
|
MessagePinEvent,
|
||||||
ReactionEvent,
|
ReactionEvent,
|
||||||
SendMessageBody,
|
SendMessageBody,
|
||||||
MediaTab,
|
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 { Publisher } from '../../shared/publisher';
|
||||||
|
|
@ -27,11 +30,12 @@ export interface MessagesServiceDeps {
|
||||||
notify: (message: Message) => void;
|
notify: (message: Message) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Telegram-style shared-media tabs → media kinds.
|
// 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[]> = {
|
const TAB_KINDS: Record<MediaTab, string[]> = {
|
||||||
media: ['image', 'video', 'video_note'],
|
media: ['image', 'video'],
|
||||||
files: ['file'],
|
files: ['file'],
|
||||||
voice: ['voice'],
|
voice: ['voice', 'video_note'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface SentMessage {
|
export interface SentMessage {
|
||||||
|
|
@ -47,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,
|
||||||
|
|
@ -55,6 +72,16 @@ export interface MessagesService {
|
||||||
): 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>;
|
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(
|
media(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|
@ -101,9 +128,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,
|
||||||
|
|
@ -114,6 +162,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 =
|
||||||
|
|
@ -148,6 +199,34 @@ 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) => {
|
media: async (conversationId, userId, tab, beforeSeq, limit) => {
|
||||||
await assertMember(conversationId, userId);
|
await assertMember(conversationId, userId);
|
||||||
const rows = await messages.listMedia(conversationId, userId, TAB_KINDS[tab], beforeSeq, limit);
|
const rows = await messages.listMedia(conversationId, userId, TAB_KINDS[tab], beforeSeq, limit);
|
||||||
|
|
@ -172,10 +251,10 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic
|
||||||
await assertMessageIn(conversationId, messageId);
|
await assertMessageIn(conversationId, messageId);
|
||||||
let deleted = await messages.softDelete(messageId, userId);
|
let deleted = await messages.softDelete(messageId, userId);
|
||||||
if (deleted === undefined) {
|
if (deleted === undefined) {
|
||||||
// Not the sender: the owner of a group may moderate any message in it.
|
// Not the sender: the owner of a group/channel may moderate any message in it.
|
||||||
const conversation = await conversations.findById(conversationId);
|
const conversation = await conversations.findById(conversationId);
|
||||||
const role = await conversations.getRole(conversationId, userId);
|
const role = await conversations.getRole(conversationId, userId);
|
||||||
if (conversation?.type !== 'group' || role !== 'owner') {
|
if (conversation === undefined || conversation.type === 'direct' || role !== 'owner') {
|
||||||
throw new HttpError(
|
throw new HttpError(
|
||||||
403,
|
403,
|
||||||
'not_deletable',
|
'not_deletable',
|
||||||
|
|
@ -201,6 +280,103 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic
|
||||||
await publish(userChannel(userId), event);
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -62,6 +74,14 @@ export const markRead = async (
|
||||||
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.
|
// "Clear history" (for me): hides all current messages; the chat stays listed.
|
||||||
export const clearConversation = async (config: ApiClientConfig, id: string): Promise<void> => {
|
export const clearConversation = async (config: ApiClientConfig, id: string): Promise<void> => {
|
||||||
await requestJson(config, 'POST', `/conversations/${id}/clear`);
|
await requestJson(config, 'POST', `/conversations/${id}/clear`);
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ export {
|
||||||
getConversation,
|
getConversation,
|
||||||
createDirect,
|
createDirect,
|
||||||
createGroup,
|
createGroup,
|
||||||
|
createChannel,
|
||||||
|
setConversationAvatar,
|
||||||
listMembers,
|
listMembers,
|
||||||
addMember,
|
addMember,
|
||||||
removeMember,
|
removeMember,
|
||||||
|
|
@ -29,9 +31,14 @@ export { listContacts, addContact, removeContact } from './contacts';
|
||||||
export {
|
export {
|
||||||
sendMessage,
|
sendMessage,
|
||||||
getHistory,
|
getHistory,
|
||||||
|
getMessageContext,
|
||||||
editMessage,
|
editMessage,
|
||||||
deleteMessage,
|
deleteMessage,
|
||||||
hideMessage,
|
hideMessage,
|
||||||
|
forwardMessage,
|
||||||
|
pinMessage,
|
||||||
|
unpinMessage,
|
||||||
|
listPinned,
|
||||||
listConversationMedia,
|
listConversationMedia,
|
||||||
addReaction,
|
addReaction,
|
||||||
removeReaction,
|
removeReaction,
|
||||||
|
|
@ -41,7 +48,7 @@ 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,
|
||||||
|
options: UploadOptions = {},
|
||||||
|
): 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);
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Upload failed with status ${String(response.status)}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import { messageSchema, messageListSchema } from '../schemas/index';
|
import { messageSchema, messageListSchema } from '../schemas/index';
|
||||||
import type { SendMessageBody, EditMessageBody, ReactionBody, MediaTab } 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,
|
||||||
|
|
@ -66,6 +94,41 @@ export const hideMessage = async (
|
||||||
await requestJson(config, 'POST', `/conversations/${conversationId}/messages/${messageId}/hide`);
|
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.
|
// Shared media for the profile panel, newest first, keyset-paginated by seq.
|
||||||
export const listConversationMedia = async (
|
export const listConversationMedia = async (
|
||||||
config: ApiClientConfig,
|
config: ApiClientConfig,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ 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',
|
||||||
|
|
@ -95,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;
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,8 @@ export const conversationSchema = {
|
||||||
'id',
|
'id',
|
||||||
'type',
|
'type',
|
||||||
'title',
|
'title',
|
||||||
|
'description',
|
||||||
|
'avatarUrl',
|
||||||
'peer',
|
'peer',
|
||||||
'createdBy',
|
'createdBy',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
|
|
@ -109,8 +111,10 @@ export const conversationSchema = {
|
||||||
],
|
],
|
||||||
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' }],
|
||||||
},
|
},
|
||||||
|
|
@ -177,6 +181,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 +222,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 +241,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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
import type { PublicUser } from './user';
|
import type { PublicUser } from './user';
|
||||||
|
|
||||||
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';
|
||||||
|
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ 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 } 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 { ChatFolder, ChatPin, FoldersState } from './folders';
|
||||||
export type {
|
export type {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,12 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Altricade</title>
|
<title>Zovi</title>
|
||||||
|
<meta name="theme-color" content="#4c6fff" />
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
BIN
packages/web/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 279 KiB |
7
packages/web/public/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 |
BIN
packages/web/public/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
packages/web/public/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
packages/web/public/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
23
packages/web/public/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"name": "Zovi",
|
||||||
|
"short_name": "Zovi",
|
||||||
|
"description": "Zovi — messaging that calls them over.",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#4c6fff",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
/* Altricade service worker — Web Push display + Telegram-style tap-to-open.
|
/* Zovi service worker — Web Push display + Telegram-style tap-to-open.
|
||||||
Plain JS (runs in the SW global scope, outside the TS/React build). */
|
Plain JS (runs in the SW global scope, outside the TS/React build). */
|
||||||
|
|
||||||
self.addEventListener('push', (event) => {
|
self.addEventListener('push', (event) => {
|
||||||
|
|
@ -14,6 +14,7 @@ self.addEventListener('push', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
self.registration.showNotification(title, {
|
self.registration.showNotification(title, {
|
||||||
body,
|
body,
|
||||||
|
icon: '/icons/icon-192.png',
|
||||||
// Collapse multiple messages from the same chat into one notification.
|
// Collapse multiple messages from the same chat into one notification.
|
||||||
tag: conversationId || undefined,
|
tag: conversationId || undefined,
|
||||||
renotify: Boolean(conversationId),
|
renotify: Boolean(conversationId),
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,27 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import type { ReactElement, SyntheticEvent } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import type { Conversation, Message, MediaRef, PublicUser, User } from '@altricade/core';
|
import type { Conversation, Message, MediaRef, PublicUser, User } from '@altricade/core';
|
||||||
import { heartbeat } from '@altricade/core/api';
|
import {
|
||||||
|
heartbeat,
|
||||||
|
getAvatarUploadUrl,
|
||||||
|
uploadToUrl,
|
||||||
|
setConversationAvatar,
|
||||||
|
forwardMessage,
|
||||||
|
} from '@altricade/core/api';
|
||||||
import { SessionProvider, useSession } from '../entities/session';
|
import { SessionProvider, useSession } from '../entities/session';
|
||||||
import { AuthForm } from '../features/auth';
|
import { AuthForm } from '../features/auth';
|
||||||
import { RealtimeProvider } from '../features/realtime';
|
import { RealtimeProvider } from '../features/realtime';
|
||||||
import { useConversations, ConversationSidebar } from '../features/conversations';
|
import {
|
||||||
|
useConversations,
|
||||||
|
ConversationSidebar,
|
||||||
|
NewGroupPanel,
|
||||||
|
NewChannelPanel,
|
||||||
|
ForwardPicker,
|
||||||
|
} from '../features/conversations';
|
||||||
import { useFolders } from '../features/folders';
|
import { useFolders } from '../features/folders';
|
||||||
import { ContactsPanel } from '../features/contacts';
|
import { ContactsPanel } from '../features/contacts';
|
||||||
import { ChatView } from '../features/messaging';
|
import { ChatView } from '../features/messaging';
|
||||||
import { ProfilePanel } from '../features/profile';
|
import { ProfilePanel, GroupInfoPanel } from '../features/profile';
|
||||||
import { SettingsPanel } from '../features/settings';
|
import { SettingsPanel } from '../features/settings';
|
||||||
import { NotificationsProvider, useNotifications, unregisterWebPush } from '../features/notifications';
|
import { NotificationsProvider, useNotifications, unregisterWebPush } from '../features/notifications';
|
||||||
import { apiConfig } from '../shared/api';
|
import { apiConfig } from '../shared/api';
|
||||||
|
|
@ -19,15 +31,20 @@ import {
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
ChatsIcon,
|
ChatsIcon,
|
||||||
SettingsIcon,
|
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
|
MegaphoneIcon,
|
||||||
|
BrandGlyph,
|
||||||
|
ContextMenu,
|
||||||
|
MenuItem,
|
||||||
|
useContextMenu,
|
||||||
} from '../shared/ui';
|
} from '../shared/ui';
|
||||||
|
|
||||||
type RailView = 'chats' | 'contacts' | 'compose' | 'settings';
|
type RailView = 'chats' | 'contacts' | 'new-group' | 'new-channel' | 'settings';
|
||||||
|
|
||||||
const RAIL_TITLES: Record<Exclude<RailView, 'chats'>, string> = {
|
const RAIL_TITLES: Record<Exclude<RailView, 'chats'>, string> = {
|
||||||
contacts: 'Contacts',
|
contacts: 'Contacts',
|
||||||
compose: 'New group',
|
'new-group': 'New group',
|
||||||
|
'new-channel': 'New channel',
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -49,65 +66,6 @@ const mediaLabel = (media: MediaRef | null): string => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const GroupComposer = ({
|
|
||||||
onCreate,
|
|
||||||
}: {
|
|
||||||
onCreate: (title: string, members: string[]) => Promise<void>;
|
|
||||||
}): ReactElement => {
|
|
||||||
const [title, setTitle] = useState('');
|
|
||||||
const [members, setMembers] = useState('');
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const submit = async (event: SyntheticEvent): Promise<void> => {
|
|
||||||
event.preventDefault();
|
|
||||||
const trimmed = title.trim();
|
|
||||||
if (trimmed === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const list = members
|
|
||||||
.split(',')
|
|
||||||
.map((name) => name.trim())
|
|
||||||
.filter((name) => name.length > 0);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await onCreate(trimmed, list);
|
|
||||||
setTitle('');
|
|
||||||
setMembers('');
|
|
||||||
} catch {
|
|
||||||
setError('Could not create group');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="compose-block">
|
|
||||||
<p className="rail-section-title">New group</p>
|
|
||||||
<form className="stack-form" onSubmit={(event) => void submit(event)}>
|
|
||||||
<input
|
|
||||||
className="field"
|
|
||||||
placeholder="Group name"
|
|
||||||
value={title}
|
|
||||||
onChange={(event) => {
|
|
||||||
setTitle(event.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="field"
|
|
||||||
placeholder="Members (comma-separated usernames)"
|
|
||||||
value={members}
|
|
||||||
onChange={(event) => {
|
|
||||||
setMembers(event.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button type="submit" className="btn-primary btn-sm">
|
|
||||||
<UsersIcon size={17} />
|
|
||||||
Create group
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{error !== null ? <p className="form-error">{error}</p> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): ReactElement => {
|
const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): ReactElement => {
|
||||||
const me: PublicUser = {
|
const me: PublicUser = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
|
|
@ -120,6 +78,8 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
|
||||||
const [railView, setRailView] = useState<RailView>('chats');
|
const [railView, setRailView] = useState<RailView>('chats');
|
||||||
const [activeFolderId, setActiveFolderId] = useState<string | null>(null);
|
const [activeFolderId, setActiveFolderId] = useState<string | null>(null);
|
||||||
const [profileUser, setProfileUser] = useState<PublicUser | null>(null);
|
const [profileUser, setProfileUser] = useState<PublicUser | null>(null);
|
||||||
|
const [groupInfo, setGroupInfo] = useState<Conversation | null>(null);
|
||||||
|
const [forwarding, setForwarding] = useState<Message[] | null>(null);
|
||||||
const convRef = useRef<Conversation[]>([]);
|
const convRef = useRef<Conversation[]>([]);
|
||||||
const folders = useFolders(user.id);
|
const folders = useFolders(user.id);
|
||||||
|
|
||||||
|
|
@ -138,16 +98,35 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
|
||||||
[notify],
|
[notify],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { conversations, onlineMap, startDirect, createGroupChat, clearChat, deleteChat } =
|
const {
|
||||||
useConversations(user.id, current?.id ?? null, onIncoming);
|
conversations,
|
||||||
|
onlineMap,
|
||||||
|
startDirect,
|
||||||
|
createGroupChat,
|
||||||
|
createChannelChat,
|
||||||
|
clearChat,
|
||||||
|
deleteChat,
|
||||||
|
} = useConversations(user.id, current?.id ?? null, onIncoming);
|
||||||
|
const composeMenu = useContextMenu<null>();
|
||||||
convRef.current = conversations;
|
convRef.current = conversations;
|
||||||
|
|
||||||
const openConversation = useCallback((conversation: Conversation): void => {
|
const openConversation = useCallback((conversation: Conversation): void => {
|
||||||
setCurrent(conversation);
|
setCurrent(conversation);
|
||||||
setRailView('chats');
|
setRailView('chats');
|
||||||
setProfileUser(null);
|
setProfileUser(null);
|
||||||
|
setGroupInfo(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const openUserProfile = useCallback(
|
||||||
|
(target: PublicUser): void => {
|
||||||
|
if (target.id !== user.id) {
|
||||||
|
setGroupInfo(null);
|
||||||
|
setProfileUser(target);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[user.id],
|
||||||
|
);
|
||||||
|
|
||||||
// The DM shared with the profiled user, when one exists (drives shared media).
|
// The DM shared with the profiled user, when one exists (drives shared media).
|
||||||
const profileConversation =
|
const profileConversation =
|
||||||
profileUser === null
|
profileUser === null
|
||||||
|
|
@ -156,11 +135,18 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
|
||||||
(item) => item.type === 'direct' && item.peer !== null && item.peer.id === profileUser.id,
|
(item) => item.type === 'direct' && item.peer !== null && item.peer.id === profileUser.id,
|
||||||
) ?? null);
|
) ?? null);
|
||||||
|
|
||||||
// If the open chat disappears from the list (deleted for me, possibly on
|
// Keep the open chat in sync with the live list: close it if it disappears
|
||||||
// another device), close it.
|
// (deleted for me / on another device), else refresh its object so header
|
||||||
|
// title/avatar changes (e.g. a new group photo) show without reopening.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (current !== null && !conversations.some((c) => c.id === current.id)) {
|
if (current === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updated = conversations.find((c) => c.id === current.id);
|
||||||
|
if (updated === undefined) {
|
||||||
setCurrent(null);
|
setCurrent(null);
|
||||||
|
} else if (updated !== current) {
|
||||||
|
setCurrent(updated);
|
||||||
}
|
}
|
||||||
}, [conversations, current]);
|
}, [conversations, current]);
|
||||||
|
|
||||||
|
|
@ -211,14 +197,47 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
|
||||||
openConversation(await startDirect(username));
|
openConversation(await startDirect(username));
|
||||||
};
|
};
|
||||||
|
|
||||||
const createGroupAndOpen = async (title: string, members: string[]): Promise<void> => {
|
// Create → optionally upload the chosen photo → open, so the new chat lands
|
||||||
openConversation(await createGroupChat(title, members));
|
// with its avatar already set.
|
||||||
|
const applyAvatar = async (conversation: Conversation, avatar: File | null): Promise<Conversation> => {
|
||||||
|
if (avatar === null) {
|
||||||
|
return conversation;
|
||||||
|
}
|
||||||
|
const mime = avatar.type.length > 0 ? avatar.type : 'application/octet-stream';
|
||||||
|
const target = await getAvatarUploadUrl(apiConfig, { mime, size: avatar.size });
|
||||||
|
await uploadToUrl(target.uploadUrl, avatar, mime);
|
||||||
|
return setConversationAvatar(apiConfig, conversation.id, { objectKey: target.objectKey });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createGroupAndOpen = async (
|
||||||
|
title: string,
|
||||||
|
members: string[],
|
||||||
|
avatar: File | null,
|
||||||
|
): Promise<void> => {
|
||||||
|
openConversation(await applyAvatar(await createGroupChat(title, members), avatar));
|
||||||
|
};
|
||||||
|
|
||||||
|
const createChannelAndOpen = async (
|
||||||
|
title: string,
|
||||||
|
description: string | null,
|
||||||
|
members: string[],
|
||||||
|
avatar: File | null,
|
||||||
|
): Promise<void> => {
|
||||||
|
openConversation(await applyAvatar(await createChannelChat(title, description, members), avatar));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Own avatar — the Telegram-style entry point to Settings (header + bottom nav).
|
||||||
|
const renderAvatar = (cls: string): ReactElement =>
|
||||||
|
user.avatarUrl !== null ? (
|
||||||
|
<img src={user.avatarUrl} alt="" className={`avatar ${cls}`} />
|
||||||
|
) : (
|
||||||
|
<span className={`avatar ${cls} avatar-placeholder`}>{user.displayName.charAt(0)}</span>
|
||||||
|
);
|
||||||
|
|
||||||
const shellClass = [
|
const shellClass = [
|
||||||
'app-shell',
|
'app-shell',
|
||||||
current !== null ? 'has-chat' : '',
|
current !== null ? 'has-chat' : '',
|
||||||
profileUser !== null ? 'has-profile' : '',
|
profileUser !== null || groupInfo !== null ? 'has-profile' : '',
|
||||||
]
|
]
|
||||||
.filter((token) => token !== '')
|
.filter((token) => token !== '')
|
||||||
.join(' ');
|
.join(' ');
|
||||||
|
|
@ -230,9 +249,9 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
|
||||||
{railView === 'chats' ? (
|
{railView === 'chats' ? (
|
||||||
<span className="rail-brand">
|
<span className="rail-brand">
|
||||||
<span className="rail-mark" aria-hidden="true">
|
<span className="rail-mark" aria-hidden="true">
|
||||||
A
|
<BrandGlyph size={21} />
|
||||||
</span>
|
</span>
|
||||||
<span className="rail-title">Altricade</span>
|
<span className="rail-title">Zovi</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="rail-brand">
|
<span className="rail-brand">
|
||||||
|
|
@ -251,41 +270,17 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
|
||||||
)}
|
)}
|
||||||
<div className="rail-header-actions">
|
<div className="rail-header-actions">
|
||||||
{railView === 'chats' ? (
|
{railView === 'chats' ? (
|
||||||
<>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
className="icon-btn"
|
||||||
className="icon-btn rail-desktop-action"
|
aria-label="New chat"
|
||||||
aria-label="Contacts"
|
title="New chat"
|
||||||
title="Contacts"
|
onClick={(event) => {
|
||||||
onClick={() => {
|
composeMenu.openAt(event, null);
|
||||||
setRailView('contacts');
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<PlusIcon />
|
||||||
<UsersIcon />
|
</button>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-btn rail-desktop-action"
|
|
||||||
aria-label="Settings"
|
|
||||||
title="Settings"
|
|
||||||
onClick={() => {
|
|
||||||
setRailView('settings');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SettingsIcon />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-btn"
|
|
||||||
aria-label="New group"
|
|
||||||
title="New group"
|
|
||||||
onClick={() => {
|
|
||||||
setRailView('compose');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PlusIcon />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -318,16 +313,37 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
|
||||||
<div className="rail-scroll compose">
|
<div className="rail-scroll compose">
|
||||||
<ContactsPanel onStartDirect={startDirectAndOpen} />
|
<ContactsPanel onStartDirect={startDirectAndOpen} />
|
||||||
</div>
|
</div>
|
||||||
) : railView === 'compose' ? (
|
) : railView === 'new-group' ? (
|
||||||
<div className="rail-scroll compose">
|
<NewGroupPanel onCreate={createGroupAndOpen} />
|
||||||
<GroupComposer onCreate={createGroupAndOpen} />
|
) : railView === 'new-channel' ? (
|
||||||
</div>
|
<NewChannelPanel onCreate={createChannelAndOpen} />
|
||||||
) : (
|
) : (
|
||||||
<div className="rail-scroll">
|
<div className="rail-scroll">
|
||||||
<SettingsPanel onLogout={onLogout} />
|
<SettingsPanel onLogout={onLogout} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{composeMenu.state !== null ? (
|
||||||
|
<ContextMenu x={composeMenu.state.x} y={composeMenu.state.y} onClose={composeMenu.close}>
|
||||||
|
<MenuItem
|
||||||
|
icon={<UsersIcon size={17} />}
|
||||||
|
label="New group"
|
||||||
|
onClick={() => {
|
||||||
|
composeMenu.close();
|
||||||
|
setRailView('new-group');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
icon={<MegaphoneIcon size={17} />}
|
||||||
|
label="New channel"
|
||||||
|
onClick={() => {
|
||||||
|
composeMenu.close();
|
||||||
|
setRailView('new-channel');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Mobile bottom navigation (Telegram-style): Contacts | Chats | Settings */}
|
{/* Mobile bottom navigation (Telegram-style): Contacts | Chats | Settings */}
|
||||||
<nav className="rail-nav" aria-label="Main">
|
<nav className="rail-nav" aria-label="Main">
|
||||||
<button
|
<button
|
||||||
|
|
@ -339,7 +355,7 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
|
||||||
setRailView('contacts');
|
setRailView('contacts');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<UserIcon size={24} />
|
<UserIcon size={28} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -351,33 +367,37 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="rail-nav-icon">
|
<span className="rail-nav-icon">
|
||||||
<ChatsIcon size={24} />
|
<ChatsIcon size={28} />
|
||||||
{totalUnread > 0 ? <span className="unread-badge rail-nav-badge">{totalUnread}</span> : null}
|
{totalUnread > 0 ? <span className="unread-badge rail-nav-badge">{totalUnread}</span> : null}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rail-nav-btn"
|
className="rail-nav-btn rail-nav-avatar-btn"
|
||||||
aria-pressed={railView === 'settings'}
|
aria-pressed={railView === 'settings'}
|
||||||
aria-label="Settings"
|
aria-label="Settings"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setRailView('settings');
|
setRailView('settings');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SettingsIcon size={24} />
|
{renderAvatar('rail-nav-avatar-img')}
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{current !== null ? (
|
{current !== null ? (
|
||||||
<ChatView
|
<ChatView
|
||||||
|
key={current.id}
|
||||||
conversation={current}
|
conversation={current}
|
||||||
me={me}
|
me={me}
|
||||||
onlineMap={onlineMap}
|
onlineMap={onlineMap}
|
||||||
onOpenProfile={(target) => {
|
onOpenProfile={openUserProfile}
|
||||||
if (target.id !== user.id) {
|
onOpenGroupInfo={(conv) => {
|
||||||
setProfileUser(target);
|
setProfileUser(null);
|
||||||
}
|
setGroupInfo(conv);
|
||||||
|
}}
|
||||||
|
onForward={(msgs) => {
|
||||||
|
setForwarding(msgs);
|
||||||
}}
|
}}
|
||||||
onBack={() => {
|
onBack={() => {
|
||||||
setCurrent(null);
|
setCurrent(null);
|
||||||
|
|
@ -387,7 +407,7 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
|
||||||
<section className="chat-pane chat-empty">
|
<section className="chat-pane chat-empty">
|
||||||
<div className="chat-empty-inner">
|
<div className="chat-empty-inner">
|
||||||
<span className="chat-empty-mark" aria-hidden="true">
|
<span className="chat-empty-mark" aria-hidden="true">
|
||||||
A
|
<BrandGlyph size={34} />
|
||||||
</span>
|
</span>
|
||||||
<h2>Select a conversation</h2>
|
<h2>Select a conversation</h2>
|
||||||
<p className="muted">Search for someone or start a new chat to begin messaging.</p>
|
<p className="muted">Search for someone or start a new chat to begin messaging.</p>
|
||||||
|
|
@ -407,6 +427,40 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
|
||||||
void startDirectAndOpen(username);
|
void startDirectAndOpen(username);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : groupInfo !== null ? (
|
||||||
|
<GroupInfoPanel
|
||||||
|
conversation={groupInfo}
|
||||||
|
meId={user.id}
|
||||||
|
onlineMap={onlineMap}
|
||||||
|
onClose={() => {
|
||||||
|
setGroupInfo(null);
|
||||||
|
}}
|
||||||
|
onOpenUser={openUserProfile}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{forwarding !== null ? (
|
||||||
|
<ForwardPicker
|
||||||
|
conversations={conversations}
|
||||||
|
count={forwarding.length}
|
||||||
|
onClose={() => {
|
||||||
|
setForwarding(null);
|
||||||
|
}}
|
||||||
|
onPick={(target, hideSender) => {
|
||||||
|
const list = forwarding;
|
||||||
|
setForwarding(null);
|
||||||
|
void (async () => {
|
||||||
|
for (const message of list) {
|
||||||
|
await forwardMessage(apiConfig, target.id, {
|
||||||
|
sourceConversationId: message.conversationId,
|
||||||
|
messageId: message.id,
|
||||||
|
hideSender,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
openConversation(target);
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -419,7 +473,7 @@ const Shell = (): ReactElement => {
|
||||||
return (
|
return (
|
||||||
<main className="splash">
|
<main className="splash">
|
||||||
<span className="splash-mark" aria-hidden="true">
|
<span className="splash-mark" aria-hidden="true">
|
||||||
A
|
<BrandGlyph size={40} />
|
||||||
</span>
|
</span>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||||
import type { ReactElement, SyntheticEvent } from 'react';
|
import type { ReactElement, SyntheticEvent } from 'react';
|
||||||
import { ApiError } from '@altricade/core/api';
|
import { ApiError } from '@altricade/core/api';
|
||||||
import { useSession } from '../../../entities/session';
|
import { useSession } from '../../../entities/session';
|
||||||
|
import { BrandGlyph } from '../../../shared/ui';
|
||||||
|
|
||||||
type Mode = 'login' | 'register';
|
type Mode = 'login' | 'register';
|
||||||
|
|
||||||
|
|
@ -36,9 +37,9 @@ export const AuthForm = (): ReactElement => {
|
||||||
<div className="auth-card">
|
<div className="auth-card">
|
||||||
<div className="auth-brand">
|
<div className="auth-brand">
|
||||||
<span className="auth-mark" aria-hidden="true">
|
<span className="auth-mark" aria-hidden="true">
|
||||||
A
|
<BrandGlyph size={32} />
|
||||||
</span>
|
</span>
|
||||||
<h1 className="auth-wordmark">Altricade</h1>
|
<h1 className="auth-wordmark">Zovi</h1>
|
||||||
<p className="auth-tagline">
|
<p className="auth-tagline">
|
||||||
{mode === 'login' ? 'Welcome back.' : 'Create your account.'}
|
{mode === 'login' ? 'Welcome back.' : 'Create your account.'}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
export { useContacts } from './model';
|
export { useContacts } from './model';
|
||||||
export type { UseContacts } from './model';
|
export type { UseContacts } from './model';
|
||||||
export { ContactsPanel } from './ui/ContactsPanel';
|
export { ContactsPanel } from './ui/ContactsPanel';
|
||||||
|
export { UserPicker } from './ui/UserPicker';
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,187 @@
|
||||||
import { useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import type { ReactElement, SyntheticEvent } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import { ApiError } from '@altricade/core/api';
|
import type { Presence, PublicUser } from '@altricade/core';
|
||||||
import { PlusIcon, TrashIcon } from '../../../shared/ui';
|
import { searchUsers, getPresence, ApiError } from '@altricade/core/api';
|
||||||
|
import { apiConfig } from '../../../shared/api';
|
||||||
|
import { SearchIcon, PlusIcon, TrashIcon, CheckIcon, CloseIcon } from '../../../shared/ui';
|
||||||
|
import { useDebouncedValue } from '../../../shared/lib';
|
||||||
import { useContacts } from '../model';
|
import { useContacts } from '../model';
|
||||||
|
|
||||||
|
const lastSeenLabel = (presence: Presence | undefined): string => {
|
||||||
|
if (presence === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (presence.online) {
|
||||||
|
return 'online';
|
||||||
|
}
|
||||||
|
const iso = presence.lastSeenAt;
|
||||||
|
if (iso === null) {
|
||||||
|
return 'last seen a long time ago';
|
||||||
|
}
|
||||||
|
const then = new Date(iso);
|
||||||
|
const now = new Date();
|
||||||
|
if (then.toDateString() === now.toDateString()) {
|
||||||
|
return `last seen at ${then.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
||||||
|
}
|
||||||
|
return `last seen ${then.toLocaleDateString([], { day: 'numeric', month: 'short' })}`;
|
||||||
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onStartDirect: (username: string) => Promise<void>;
|
onStartDirect: (username: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Contacts page: typeahead search (username / name / phone) with add-from-list,
|
||||||
|
// plus the saved contact list (click to chat, trash to remove).
|
||||||
export const ContactsPanel = ({ onStartDirect }: Props): ReactElement => {
|
export const ContactsPanel = ({ onStartDirect }: Props): ReactElement => {
|
||||||
const { contacts, add, remove } = useContacts();
|
const { contacts, add, remove } = useContacts();
|
||||||
const [name, setName] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
|
const [results, setResults] = useState<PublicUser[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [presenceMap, setPresenceMap] = useState<Map<string, Presence>>(new Map());
|
||||||
|
|
||||||
const submit = async (event: SyntheticEvent): Promise<void> => {
|
const contactIds = useMemo(
|
||||||
event.preventDefault();
|
() => new Set(contacts.map((contact) => contact.userId)),
|
||||||
const trimmed = name.trim();
|
[contacts],
|
||||||
if (trimmed === '') {
|
);
|
||||||
return;
|
|
||||||
|
// Bulk presence for the saved-contact list (drives the "last seen" subtitle).
|
||||||
|
const contactIdKey = useMemo(
|
||||||
|
() => contacts.map((contact) => contact.userId).sort((a, b) => a.localeCompare(b)).join(','),
|
||||||
|
[contacts],
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const ids = contactIdKey.split(',').filter((id) => id.length > 0);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
void getPresence(apiConfig, ids).then((list) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setPresenceMap(new Map(list.map((entry) => [entry.userId, entry])));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [contactIdKey]);
|
||||||
|
|
||||||
|
const debouncedQuery = useDebouncedValue(query.trim(), 300);
|
||||||
|
|
||||||
|
// Debounced search-as-you-type (username / name / phone).
|
||||||
|
useEffect(() => {
|
||||||
|
if (debouncedQuery.length === 0) {
|
||||||
|
setResults([]);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
void searchUsers(apiConfig, debouncedQuery)
|
||||||
|
.then((found) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setResults(found);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setResults([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [debouncedQuery]);
|
||||||
|
|
||||||
|
const addContact = async (user: PublicUser): Promise<void> => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await add(trimmed);
|
await add(user.username);
|
||||||
setName('');
|
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
setError(caught instanceof ApiError ? caught.message : 'Could not add contact');
|
setError(caught instanceof ApiError ? caught.message : 'Could not add contact');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const searching = query.trim().length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="compose-block">
|
<div className="compose-block">
|
||||||
<p className="rail-section-title">Contacts</p>
|
<div className="rail-search picker-search">
|
||||||
<form className="inline-add" onSubmit={(event) => void submit(event)}>
|
<SearchIcon size={18} className="rail-search-icon" />
|
||||||
<input
|
<input
|
||||||
className="field"
|
className="rail-search-input"
|
||||||
placeholder="Add by username"
|
placeholder="Search by name, username or phone…"
|
||||||
value={name}
|
value={query}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setName(event.target.value);
|
setQuery(event.target.value);
|
||||||
|
setError(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="icon-btn accent" aria-label="Add contact">
|
{searching ? (
|
||||||
<PlusIcon size={18} />
|
<button
|
||||||
</button>
|
type="button"
|
||||||
</form>
|
className="rail-search-clear"
|
||||||
|
aria-label="Clear search"
|
||||||
|
onClick={() => {
|
||||||
|
setQuery('');
|
||||||
|
setResults([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseIcon size={16} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
{error !== null ? <p className="form-error">{error}</p> : null}
|
{error !== null ? <p className="form-error">{error}</p> : null}
|
||||||
{contacts.length === 0 ? (
|
|
||||||
<p className="rail-empty">No contacts yet</p>
|
{searching ? (
|
||||||
|
<div className="pick-list">
|
||||||
|
<p className="rail-section-title">Matches</p>
|
||||||
|
{results.length === 0 ? (
|
||||||
|
<p className="rail-empty">No matches</p>
|
||||||
|
) : (
|
||||||
|
results.map((user) => {
|
||||||
|
const isContact = contactIds.has(user.id);
|
||||||
|
return (
|
||||||
|
<div key={user.id} className="pick-row contact-match">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="contact-open"
|
||||||
|
onClick={() => {
|
||||||
|
void onStartDirect(user.username);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.avatarUrl !== null ? (
|
||||||
|
<img src={user.avatarUrl} alt="" className="avatar pick-avatar" />
|
||||||
|
) : (
|
||||||
|
<span className="avatar pick-avatar avatar-placeholder">
|
||||||
|
{user.displayName.charAt(0)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="pick-main">
|
||||||
|
<span className="pick-name">{user.displayName}</span>
|
||||||
|
<span className="pick-sub">@{user.username}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{isContact ? (
|
||||||
|
<span className="contact-added" title="In contacts">
|
||||||
|
<CheckIcon size={16} />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn contact-add"
|
||||||
|
aria-label={`Add ${user.displayName} to contacts`}
|
||||||
|
onClick={() => {
|
||||||
|
void addContact(user);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusIcon size={17} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : contacts.length === 0 ? (
|
||||||
|
<p className="rail-empty">No contacts yet — search to add people</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="contact-list">
|
<ul className="contact-list">
|
||||||
{contacts.map((contact) => (
|
{contacts.map((contact) => (
|
||||||
|
|
@ -67,7 +202,15 @@ export const ContactsPanel = ({ onStartDirect }: Props): ReactElement => {
|
||||||
)}
|
)}
|
||||||
<span className="contact-names">
|
<span className="contact-names">
|
||||||
<span className="contact-name">{contact.user.displayName}</span>
|
<span className="contact-name">{contact.user.displayName}</span>
|
||||||
<span className="muted contact-handle">@{contact.user.username}</span>
|
<span
|
||||||
|
className={
|
||||||
|
presenceMap.get(contact.userId)?.online === true
|
||||||
|
? 'contact-handle contact-online'
|
||||||
|
: 'muted contact-handle'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{lastSeenLabel(presenceMap.get(contact.userId)) || `@${contact.user.username}`}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
180
packages/web/src/features/contacts/ui/UserPicker.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import type { Presence, PublicUser } from '@altricade/core';
|
||||||
|
import { searchUsers, getPresence } from '@altricade/core/api';
|
||||||
|
import { apiConfig } from '../../../shared/api';
|
||||||
|
import { SearchIcon, CheckIcon, CloseIcon } from '../../../shared/ui';
|
||||||
|
import { useDebouncedValue } from '../../../shared/lib';
|
||||||
|
import { useContacts } from '../model';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selected: PublicUser[];
|
||||||
|
onToggle: (user: PublicUser) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSeenLabel = (presence: Presence | undefined): string => {
|
||||||
|
if (presence === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (presence.online) {
|
||||||
|
return 'online';
|
||||||
|
}
|
||||||
|
const iso = presence.lastSeenAt;
|
||||||
|
if (iso === null) {
|
||||||
|
return 'last seen a long time ago';
|
||||||
|
}
|
||||||
|
const then = new Date(iso);
|
||||||
|
const now = new Date();
|
||||||
|
if (then.toDateString() === now.toDateString()) {
|
||||||
|
return `last seen at ${then.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
||||||
|
}
|
||||||
|
return `last seen ${then.toLocaleDateString([], { day: 'numeric', month: 'short' })}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Telegram-style multi-select user list: selected chips + one search over both
|
||||||
|
// the contact list and the backend (username / display name / phone).
|
||||||
|
export const UserPicker = ({ selected, onToggle }: Props): ReactElement => {
|
||||||
|
const { contacts } = useContacts();
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [results, setResults] = useState<PublicUser[]>([]);
|
||||||
|
const [presenceMap, setPresenceMap] = useState<Map<string, Presence>>(new Map());
|
||||||
|
|
||||||
|
const contactUsers = useMemo(() => contacts.map((contact) => contact.user), [contacts]);
|
||||||
|
const contactIds = useMemo(() => new Set(contactUsers.map((user) => user.id)), [contactUsers]);
|
||||||
|
const selectedIds = useMemo(() => new Set(selected.map((user) => user.id)), [selected]);
|
||||||
|
|
||||||
|
// One bulk presence fetch for the contact list (drives the "last seen" line).
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const ids = contactUsers.map((user) => user.id);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
void getPresence(apiConfig, ids).then((list) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setPresenceMap(new Map(list.map((entry) => [entry.userId, entry])));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [contactUsers]);
|
||||||
|
|
||||||
|
const trimmed = query.trim().toLowerCase();
|
||||||
|
const debouncedQuery = useDebouncedValue(query.trim(), 300);
|
||||||
|
|
||||||
|
// Debounced global search; local contact filtering below stays instant.
|
||||||
|
useEffect(() => {
|
||||||
|
if (debouncedQuery.length === 0) {
|
||||||
|
setResults([]);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
void searchUsers(apiConfig, debouncedQuery)
|
||||||
|
.then((found) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setResults(found);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setResults([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [debouncedQuery]);
|
||||||
|
|
||||||
|
const matchingContacts =
|
||||||
|
trimmed === ''
|
||||||
|
? contactUsers
|
||||||
|
: contactUsers.filter(
|
||||||
|
(user) =>
|
||||||
|
user.username.toLowerCase().startsWith(trimmed) ||
|
||||||
|
user.displayName.toLowerCase().startsWith(trimmed),
|
||||||
|
);
|
||||||
|
const globalResults = results.filter((user) => !contactIds.has(user.id));
|
||||||
|
|
||||||
|
const row = (user: PublicUser, sub: string): ReactElement => {
|
||||||
|
const isSelected = selectedIds.has(user.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={user.id}
|
||||||
|
type="button"
|
||||||
|
className="pick-row"
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
onClick={() => {
|
||||||
|
onToggle(user);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.avatarUrl !== null ? (
|
||||||
|
<img src={user.avatarUrl} alt="" className="avatar pick-avatar" />
|
||||||
|
) : (
|
||||||
|
<span className="avatar pick-avatar avatar-placeholder">{user.displayName.charAt(0)}</span>
|
||||||
|
)}
|
||||||
|
<span className="pick-main">
|
||||||
|
<span className="pick-name">{user.displayName}</span>
|
||||||
|
{sub !== '' ? <span className="pick-sub">{sub}</span> : null}
|
||||||
|
</span>
|
||||||
|
<span className={isSelected ? 'pick-check is-on' : 'pick-check'} aria-hidden="true">
|
||||||
|
{isSelected ? <CheckIcon size={14} /> : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="picker">
|
||||||
|
{selected.length > 0 ? (
|
||||||
|
<div className="pick-chips">
|
||||||
|
{selected.map((user) => (
|
||||||
|
<button
|
||||||
|
key={user.id}
|
||||||
|
type="button"
|
||||||
|
className="pick-chip"
|
||||||
|
onClick={() => {
|
||||||
|
onToggle(user);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.displayName}
|
||||||
|
<CloseIcon size={13} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="rail-search picker-search">
|
||||||
|
<SearchIcon size={18} className="rail-search-icon" />
|
||||||
|
<input
|
||||||
|
className="rail-search-input"
|
||||||
|
placeholder="Who would you like to add?"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => {
|
||||||
|
setQuery(event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pick-list">
|
||||||
|
{matchingContacts.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<p className="rail-section-title">Contacts</p>
|
||||||
|
{matchingContacts.map((user) =>
|
||||||
|
row(user, lastSeenLabel(presenceMap.get(user.id)) || `@${user.username}`),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{globalResults.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<p className="rail-section-title">Global search</p>
|
||||||
|
{globalResults.map((user) => row(user, `@${user.username}`))}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{matchingContacts.length === 0 && globalResults.length === 0 ? (
|
||||||
|
<p className="rail-empty">{trimmed === '' ? 'No contacts yet' : 'No matches'}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
export { useConversations } from './model';
|
export { useConversations } from './model';
|
||||||
export type { UseConversations } from './model';
|
export type { UseConversations } from './model';
|
||||||
export { ConversationSidebar } from './ui/ConversationSidebar';
|
export { ConversationSidebar } from './ui/ConversationSidebar';
|
||||||
|
export { NewGroupPanel } from './ui/NewGroupPanel';
|
||||||
|
export { NewChannelPanel } from './ui/NewChannelPanel';
|
||||||
|
export { ForwardPicker } from './ui/ForwardPicker';
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
listConversations,
|
listConversations,
|
||||||
createDirect,
|
createDirect,
|
||||||
createGroup,
|
createGroup,
|
||||||
|
createChannel,
|
||||||
clearConversation,
|
clearConversation,
|
||||||
hideConversation,
|
hideConversation,
|
||||||
} from '@altricade/core/api';
|
} from '@altricade/core/api';
|
||||||
|
|
@ -17,6 +18,11 @@ export interface UseConversations {
|
||||||
onlineMap: Record<string, boolean>;
|
onlineMap: Record<string, boolean>;
|
||||||
startDirect: (username: string) => Promise<Conversation>;
|
startDirect: (username: string) => Promise<Conversation>;
|
||||||
createGroupChat: (title: string, members: string[]) => Promise<Conversation>;
|
createGroupChat: (title: string, members: string[]) => Promise<Conversation>;
|
||||||
|
createChannelChat: (
|
||||||
|
title: string,
|
||||||
|
description: string | null,
|
||||||
|
members: string[],
|
||||||
|
) => Promise<Conversation>;
|
||||||
/** Clear history for me — the chat stays listed with an empty scrollback. */
|
/** Clear history for me — the chat stays listed with an empty scrollback. */
|
||||||
clearChat: (conversationId: string) => Promise<void>;
|
clearChat: (conversationId: string) => Promise<void>;
|
||||||
/** Delete chat for me — removed from the list until new activity arrives. */
|
/** Delete chat for me — removed from the list until new activity arrives. */
|
||||||
|
|
@ -212,6 +218,19 @@ export const useConversations = (
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const createChannelChat = useCallback(
|
||||||
|
async (title: string, description: string | null, members: string[]): Promise<Conversation> => {
|
||||||
|
const conversation = await createChannel(apiConfig, {
|
||||||
|
title,
|
||||||
|
...(description !== null ? { description } : {}),
|
||||||
|
members,
|
||||||
|
});
|
||||||
|
setConversations((prev) => upsert(prev, conversation));
|
||||||
|
return conversation;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const clearChat = useCallback(async (conversationId: string): Promise<void> => {
|
const clearChat = useCallback(async (conversationId: string): Promise<void> => {
|
||||||
await clearConversation(apiConfig, conversationId);
|
await clearConversation(apiConfig, conversationId);
|
||||||
setConversations((prev) =>
|
setConversations((prev) =>
|
||||||
|
|
@ -225,5 +244,14 @@ export const useConversations = (
|
||||||
await hideConversation(apiConfig, conversationId);
|
await hideConversation(apiConfig, conversationId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { conversations, loading, onlineMap, startDirect, createGroupChat, clearChat, deleteChat };
|
return {
|
||||||
|
conversations,
|
||||||
|
loading,
|
||||||
|
onlineMap,
|
||||||
|
startDirect,
|
||||||
|
createGroupChat,
|
||||||
|
createChannelChat,
|
||||||
|
clearChat,
|
||||||
|
deleteChat,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
51
packages/web/src/features/conversations/ui/AvatarPicker.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { ReactElement, ReactNode, SyntheticEvent } from 'react';
|
||||||
|
import { CameraIcon } from '../../../shared/ui';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
file: File | null;
|
||||||
|
onPick: (file: File) => void;
|
||||||
|
/** Placeholder icon shown before a photo is chosen. */
|
||||||
|
icon: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round avatar picker for the creation wizards: shows a live preview of the
|
||||||
|
// selected image, or a camera placeholder (Telegram "New Channel" style).
|
||||||
|
export const AvatarPicker = ({ file, onPick, icon }: Props): ReactElement => {
|
||||||
|
const [preview, setPreview] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (file === null) {
|
||||||
|
setPreview(null);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
setPreview(url);
|
||||||
|
return () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
}, [file]);
|
||||||
|
|
||||||
|
const onFile = (event: SyntheticEvent<HTMLInputElement>): void => {
|
||||||
|
const input = event.currentTarget;
|
||||||
|
const picked = input.files?.[0];
|
||||||
|
input.value = '';
|
||||||
|
if (picked !== undefined) {
|
||||||
|
onPick(picked);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="wizard-avatar" title="Add photo">
|
||||||
|
{preview !== null ? (
|
||||||
|
<img src={preview} alt="" className="wizard-avatar-img" />
|
||||||
|
) : (
|
||||||
|
<span className="wizard-avatar-ph">{icon}</span>
|
||||||
|
)}
|
||||||
|
<span className="wizard-avatar-cam">
|
||||||
|
<CameraIcon size={15} />
|
||||||
|
</span>
|
||||||
|
<input type="file" accept="image/*" hidden onChange={onFile} />
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { ReactElement, SyntheticEvent } from 'react';
|
import type { ReactElement, SyntheticEvent } from 'react';
|
||||||
import type { ChatFolder, Conversation, PublicUser } from '@altricade/core';
|
import type { ChatFolder, Conversation, PublicUser } from '@altricade/core';
|
||||||
import { searchUsers } from '@altricade/core/api';
|
import { searchUsers } from '@altricade/core/api';
|
||||||
import { apiConfig } from '../../../shared/api';
|
import { apiConfig } from '../../../shared/api';
|
||||||
|
import { useDebouncedValue } from '../../../shared/lib';
|
||||||
import {
|
import {
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
|
MegaphoneIcon,
|
||||||
CloseIcon,
|
CloseIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
PinIcon,
|
PinIcon,
|
||||||
|
|
@ -44,13 +46,16 @@ type SidebarDialog =
|
||||||
| { kind: 'delete-chat'; conversation: Conversation };
|
| { kind: 'delete-chat'; conversation: Conversation };
|
||||||
|
|
||||||
const title = (conversation: Conversation): string => {
|
const title = (conversation: Conversation): string => {
|
||||||
if (conversation.type === 'group') {
|
if (conversation.type !== 'direct') {
|
||||||
return conversation.title ?? 'Group';
|
return conversation.title ?? (conversation.type === 'channel' ? 'Channel' : 'Group');
|
||||||
}
|
}
|
||||||
return conversation.peer === null ? 'Direct' : conversation.peer.displayName;
|
return conversation.peer === null ? 'Direct' : conversation.peer.displayName;
|
||||||
};
|
};
|
||||||
|
|
||||||
const subtitle = (conversation: Conversation): string => {
|
const subtitle = (conversation: Conversation): string => {
|
||||||
|
if (conversation.type === 'channel') {
|
||||||
|
return 'Channel';
|
||||||
|
}
|
||||||
if (conversation.type === 'group') {
|
if (conversation.type === 'group') {
|
||||||
return 'Group';
|
return 'Group';
|
||||||
}
|
}
|
||||||
|
|
@ -81,13 +86,18 @@ const formatTime = (iso: string): string => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const Avatar = ({ conversation }: { conversation: Conversation }): ReactElement => {
|
const Avatar = ({ conversation }: { conversation: Conversation }): ReactElement => {
|
||||||
const peer = conversation.peer;
|
if (conversation.avatarUrl !== null) {
|
||||||
if (peer !== null && peer.avatarUrl !== null) {
|
return <img src={conversation.avatarUrl} alt="" className="avatar conv-avatar" />;
|
||||||
return <img src={peer.avatarUrl} alt="" className="avatar conv-avatar" />;
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="avatar conv-avatar avatar-placeholder">
|
<span className="avatar conv-avatar avatar-placeholder">
|
||||||
{conversation.type === 'group' ? <UsersIcon size={20} /> : initial(conversation)}
|
{conversation.type === 'channel' ? (
|
||||||
|
<MegaphoneIcon size={20} />
|
||||||
|
) : conversation.type === 'group' ? (
|
||||||
|
<UsersIcon size={20} />
|
||||||
|
) : (
|
||||||
|
initial(conversation)
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -173,19 +183,30 @@ export const ConversationSidebar = ({
|
||||||
const [dialog, setDialog] = useState<SidebarDialog | null>(null);
|
const [dialog, setDialog] = useState<SidebarDialog | null>(null);
|
||||||
const chatMenu = useContextMenu<Conversation>();
|
const chatMenu = useContextMenu<Conversation>();
|
||||||
const folderMenu = useContextMenu<ChatFolder>();
|
const folderMenu = useContextMenu<ChatFolder>();
|
||||||
|
const debouncedQuery = useDebouncedValue(query.trim(), 300);
|
||||||
|
|
||||||
const runSearch = async (value: string): Promise<void> => {
|
// Debounced people search — one request per quiet period, latest wins.
|
||||||
setQuery(value);
|
useEffect(() => {
|
||||||
if (value.trim().length === 0) {
|
if (debouncedQuery.length === 0) {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
try {
|
let cancelled = false;
|
||||||
setResults(await searchUsers(apiConfig, value.trim()));
|
void searchUsers(apiConfig, debouncedQuery)
|
||||||
} catch {
|
.then((found) => {
|
||||||
setResults([]);
|
if (!cancelled) {
|
||||||
}
|
setResults(found);
|
||||||
};
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setResults([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [debouncedQuery]);
|
||||||
|
|
||||||
const start = async (username: string): Promise<void> => {
|
const start = async (username: string): Promise<void> => {
|
||||||
await onStartDirect(username);
|
await onStartDirect(username);
|
||||||
|
|
@ -225,7 +246,7 @@ export const ConversationSidebar = ({
|
||||||
placeholder="Search people…"
|
placeholder="Search people…"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
void runSearch(event.target.value);
|
setQuery(event.target.value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{searching ? (
|
{searching ? (
|
||||||
|
|
|
||||||
118
packages/web/src/features/conversations/ui/ForwardPicker.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import type { Conversation } from '@altricade/core';
|
||||||
|
import { SearchIcon, CloseIcon, UsersIcon, MegaphoneIcon } from '../../../shared/ui';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
conversations: Conversation[];
|
||||||
|
/** How many messages are being forwarded (for the header). */
|
||||||
|
count: number;
|
||||||
|
onPick: (target: Conversation, hideSender: boolean) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = (conversation: Conversation): string => {
|
||||||
|
if (conversation.type !== 'direct') {
|
||||||
|
return conversation.title ?? (conversation.type === 'channel' ? 'Channel' : 'Group');
|
||||||
|
}
|
||||||
|
return conversation.peer?.displayName ?? 'Direct';
|
||||||
|
};
|
||||||
|
|
||||||
|
const initial = (conversation: Conversation): string => label(conversation).charAt(0);
|
||||||
|
|
||||||
|
// Telegram-style "Forward to…" chat picker with a search field and a
|
||||||
|
// hide-sender toggle. Channels the user can't post to are excluded.
|
||||||
|
export const ForwardPicker = ({ conversations, count, onPick, onClose }: Props): ReactElement => {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [hideSender, setHideSender] = useState(false);
|
||||||
|
|
||||||
|
const results = useMemo(() => {
|
||||||
|
const trimmed = query.trim().toLowerCase();
|
||||||
|
if (trimmed === '') {
|
||||||
|
return conversations;
|
||||||
|
}
|
||||||
|
return conversations.filter((conversation) =>
|
||||||
|
label(conversation).toLowerCase().includes(trimmed),
|
||||||
|
);
|
||||||
|
}, [conversations, query]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dialog-backdrop" role="presentation" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="forward-panel"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Forward to"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="forward-header">
|
||||||
|
<span className="forward-title">
|
||||||
|
Forward {count > 1 ? `${String(count)} messages` : 'message'}
|
||||||
|
</span>
|
||||||
|
<button type="button" className="icon-btn" aria-label="Close" onClick={onClose}>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rail-search picker-search">
|
||||||
|
<SearchIcon size={18} className="rail-search-icon" />
|
||||||
|
<input
|
||||||
|
className="rail-search-input"
|
||||||
|
placeholder="Search chats…"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => {
|
||||||
|
setQuery(event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="forward-hide">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hideSender}
|
||||||
|
onChange={(event) => {
|
||||||
|
setHideSender(event.target.checked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>Hide sender name</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="pick-list forward-list">
|
||||||
|
{results.length === 0 ? (
|
||||||
|
<p className="rail-empty">No chats</p>
|
||||||
|
) : (
|
||||||
|
results.map((conversation) => (
|
||||||
|
<button
|
||||||
|
key={conversation.id}
|
||||||
|
type="button"
|
||||||
|
className="pick-row"
|
||||||
|
onClick={() => {
|
||||||
|
onPick(conversation, hideSender);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{conversation.avatarUrl !== null ? (
|
||||||
|
<img src={conversation.avatarUrl} alt="" className="avatar pick-avatar" />
|
||||||
|
) : (
|
||||||
|
<span className="avatar pick-avatar avatar-placeholder">
|
||||||
|
{conversation.type === 'channel' ? (
|
||||||
|
<MegaphoneIcon size={18} />
|
||||||
|
) : conversation.type === 'group' ? (
|
||||||
|
<UsersIcon size={18} />
|
||||||
|
) : (
|
||||||
|
initial(conversation)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="pick-main">
|
||||||
|
<span className="pick-name">{label(conversation)}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
141
packages/web/src/features/conversations/ui/NewChannelPanel.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { ReactElement, SyntheticEvent } from 'react';
|
||||||
|
import type { PublicUser } from '@altricade/core';
|
||||||
|
import { MegaphoneIcon } from '../../../shared/ui';
|
||||||
|
import { UserPicker } from '../../contacts';
|
||||||
|
import { AvatarPicker } from './AvatarPicker';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onCreate: (
|
||||||
|
title: string,
|
||||||
|
description: string | null,
|
||||||
|
members: string[],
|
||||||
|
avatar: File | null,
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Telegram-style channel creation: name + optional description, then add
|
||||||
|
// subscribers. Only the owner (and later admins) can post in the result.
|
||||||
|
export const NewChannelPanel = ({ onCreate }: Props): ReactElement => {
|
||||||
|
const [step, setStep] = useState<'info' | 'members'>('info');
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [avatar, setAvatar] = useState<File | null>(null);
|
||||||
|
const [selected, setSelected] = useState<PublicUser[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const toggle = (user: PublicUser): void => {
|
||||||
|
setSelected((prev) =>
|
||||||
|
prev.some((item) => item.id === user.id)
|
||||||
|
? prev.filter((item) => item.id !== user.id)
|
||||||
|
: [...prev, user],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = (event: SyntheticEvent): void => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (title.trim() !== '') {
|
||||||
|
setStep('members');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const create = async (): Promise<void> => {
|
||||||
|
if (busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const trimmedDescription = description.trim();
|
||||||
|
await onCreate(
|
||||||
|
title.trim(),
|
||||||
|
trimmedDescription === '' ? null : trimmedDescription,
|
||||||
|
selected.map((user) => user.username),
|
||||||
|
avatar,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setError('Could not create channel');
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (step === 'info') {
|
||||||
|
return (
|
||||||
|
<div className="wizard">
|
||||||
|
<form className="wizard-form" onSubmit={next}>
|
||||||
|
<div className="wizard-name-row">
|
||||||
|
<AvatarPicker file={avatar} onPick={setAvatar} icon={<MegaphoneIcon size={24} />} />
|
||||||
|
<label className="field-label wizard-name-field">
|
||||||
|
Channel name
|
||||||
|
<input
|
||||||
|
className="field"
|
||||||
|
autoFocus
|
||||||
|
maxLength={100}
|
||||||
|
placeholder="Channel name"
|
||||||
|
value={title}
|
||||||
|
onChange={(event) => {
|
||||||
|
setTitle(event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="field-label">
|
||||||
|
Description
|
||||||
|
<textarea
|
||||||
|
className="field wizard-desc"
|
||||||
|
maxLength={500}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Description (optional)"
|
||||||
|
value={description}
|
||||||
|
onChange={(event) => {
|
||||||
|
setDescription(event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="muted wizard-note">
|
||||||
|
Only you can post in the channel; members subscribe and read.
|
||||||
|
</p>
|
||||||
|
<div className="wizard-actions">
|
||||||
|
<button type="submit" className="btn-primary btn-sm" disabled={title.trim() === ''}>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wizard">
|
||||||
|
<div className="wizard-bar">
|
||||||
|
<span className="wizard-count">
|
||||||
|
{selected.length === 0 ? 'Add subscribers' : `${String(selected.length)} selected`}
|
||||||
|
</span>
|
||||||
|
<div className="wizard-bar-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setStep('info');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary btn-sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
void create();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create channel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error !== null ? <p className="form-error">{error}</p> : null}
|
||||||
|
<UserPicker selected={selected} onToggle={toggle} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
115
packages/web/src/features/conversations/ui/NewGroupPanel.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { ReactElement, SyntheticEvent } from 'react';
|
||||||
|
import type { PublicUser } from '@altricade/core';
|
||||||
|
import { UsersIcon } from '../../../shared/ui';
|
||||||
|
import { UserPicker } from '../../contacts';
|
||||||
|
import { AvatarPicker } from './AvatarPicker';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onCreate: (title: string, members: string[], avatar: File | null) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Telegram-style two-step group creation: pick members, then name the group.
|
||||||
|
export const NewGroupPanel = ({ onCreate }: Props): ReactElement => {
|
||||||
|
const [step, setStep] = useState<'members' | 'name'>('members');
|
||||||
|
const [selected, setSelected] = useState<PublicUser[]>([]);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [avatar, setAvatar] = useState<File | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const toggle = (user: PublicUser): void => {
|
||||||
|
setSelected((prev) =>
|
||||||
|
prev.some((item) => item.id === user.id)
|
||||||
|
? prev.filter((item) => item.id !== user.id)
|
||||||
|
: [...prev, user],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async (event: SyntheticEvent): Promise<void> => {
|
||||||
|
event.preventDefault();
|
||||||
|
const trimmed = title.trim();
|
||||||
|
if (trimmed === '' || busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await onCreate(
|
||||||
|
trimmed,
|
||||||
|
selected.map((user) => user.username),
|
||||||
|
avatar,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setError('Could not create group');
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (step === 'members') {
|
||||||
|
return (
|
||||||
|
<div className="wizard">
|
||||||
|
<div className="wizard-bar">
|
||||||
|
<span className="wizard-count">
|
||||||
|
{selected.length === 0 ? 'Add members' : `${String(selected.length)} selected`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary btn-sm"
|
||||||
|
onClick={() => {
|
||||||
|
setStep('name');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<UserPicker selected={selected} onToggle={toggle} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wizard">
|
||||||
|
<form className="wizard-form" onSubmit={(event) => void submit(event)}>
|
||||||
|
<div className="wizard-name-row">
|
||||||
|
<AvatarPicker file={avatar} onPick={setAvatar} icon={<UsersIcon size={24} />} />
|
||||||
|
<label className="field-label wizard-name-field">
|
||||||
|
Group name
|
||||||
|
<input
|
||||||
|
className="field"
|
||||||
|
autoFocus
|
||||||
|
maxLength={100}
|
||||||
|
placeholder="Group name"
|
||||||
|
value={title}
|
||||||
|
onChange={(event) => {
|
||||||
|
setTitle(event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="muted wizard-note">
|
||||||
|
{selected.length === 0
|
||||||
|
? 'No members yet — you can add people later.'
|
||||||
|
: `${String(selected.length)} member${selected.length === 1 ? '' : 's'}: ${selected
|
||||||
|
.map((user) => user.displayName)
|
||||||
|
.join(', ')}`}
|
||||||
|
</p>
|
||||||
|
{error !== null ? <p className="form-error">{error}</p> : null}
|
||||||
|
<div className="wizard-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setStep('members');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn-primary btn-sm" disabled={title.trim() === '' || busy}>
|
||||||
|
Create group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -2,4 +2,6 @@ export { useConversationMessages } from './model';
|
||||||
export type { UseConversationMessages } from './model';
|
export type { UseConversationMessages } from './model';
|
||||||
export { ChatView } from './ui/ChatView';
|
export { ChatView } from './ui/ChatView';
|
||||||
export { VoiceMessage } from './ui/VoiceMessage';
|
export { VoiceMessage } from './ui/VoiceMessage';
|
||||||
|
export { VideoMessage } from './ui/VideoMessage';
|
||||||
|
export { VideoThumb } from './ui/VideoThumb';
|
||||||
export { MediaViewerProvider, useMediaViewer } from './ui/MediaViewer';
|
export { MediaViewerProvider, useMediaViewer } from './ui/MediaViewer';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type {
|
import type {
|
||||||
Conversation,
|
Conversation,
|
||||||
Message,
|
Message,
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
} from '@altricade/core';
|
} from '@altricade/core';
|
||||||
import {
|
import {
|
||||||
getHistory,
|
getHistory,
|
||||||
|
getMessageContext,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
editMessage as apiEdit,
|
editMessage as apiEdit,
|
||||||
deleteMessage as apiDelete,
|
deleteMessage as apiDelete,
|
||||||
|
|
@ -34,19 +35,61 @@ import {
|
||||||
markRead,
|
markRead,
|
||||||
getUploadUrl,
|
getUploadUrl,
|
||||||
uploadToUrl,
|
uploadToUrl,
|
||||||
|
listPinned,
|
||||||
} from '@altricade/core/api';
|
} from '@altricade/core/api';
|
||||||
import type { RealtimeEvent } from '@altricade/core/realtime';
|
import type { RealtimeEvent } from '@altricade/core/realtime';
|
||||||
import { apiConfig } from '../../shared/api';
|
import { apiConfig } from '../../shared/api';
|
||||||
import { useRealtime } from '../realtime';
|
import { useRealtime } from '../realtime';
|
||||||
|
|
||||||
|
export type UploadStatus = 'uploading' | 'sending' | 'error';
|
||||||
|
|
||||||
|
export interface PendingUpload {
|
||||||
|
/** Doubles as the message clientMsgId — stable across retries so the backend dedupes the send. */
|
||||||
|
id: string;
|
||||||
|
conversationId: string;
|
||||||
|
media: MediaRef;
|
||||||
|
caption: string;
|
||||||
|
/** Object URL for a local image/video preview while the bytes are in flight. */
|
||||||
|
previewUrl: string | null;
|
||||||
|
/** Uploaded fraction, 0..1 (meaningful while status is 'uploading'). */
|
||||||
|
progress: number;
|
||||||
|
status: UploadStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UseConversationMessages {
|
export interface UseConversationMessages {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
typingUserIds: string[];
|
typingUserIds: string[];
|
||||||
/** For a direct conversation: the peer's last-read seq (drives ✓✓). */
|
/** For a direct conversation: the peer's last-read seq (drives ✓✓). */
|
||||||
peerReadSeq: number;
|
peerReadSeq: number;
|
||||||
send: (content: string) => Promise<void>;
|
send: (content: string, replyToId?: string) => Promise<void>;
|
||||||
sendMedia: (file: File, caption: string, kindOverride?: MediaKind) => Promise<void>;
|
sendMedia: (
|
||||||
|
file: File,
|
||||||
|
caption: string,
|
||||||
|
kindOverride?: MediaKind,
|
||||||
|
durationSec?: number,
|
||||||
|
) => Promise<void>;
|
||||||
|
/** In-flight media sends for this conversation, oldest first. */
|
||||||
|
uploads: PendingUpload[];
|
||||||
|
/** Re-run a failed upload from the step that failed (same clientMsgId → no duplicate message). */
|
||||||
|
retryUpload: (uploadId: string) => void;
|
||||||
|
/** Abort (if in flight) and discard a pending upload. */
|
||||||
|
cancelUpload: (uploadId: string) => void;
|
||||||
|
/** Pinned messages for this conversation, newest pin first. */
|
||||||
|
pinned: Message[];
|
||||||
|
/** True when the loaded window is a jumped-to context, not the live tail. */
|
||||||
|
detached: boolean;
|
||||||
|
/** False once the top of the conversation has been reached. */
|
||||||
|
hasMoreUp: boolean;
|
||||||
|
/** Ensure `messageId` is in the window (fetching its context when needed).
|
||||||
|
* The jump-to-message primitive shared by replies, pins, and future search. */
|
||||||
|
jumpTo: (messageId: string) => Promise<boolean>;
|
||||||
|
/** Prepend an older page (scroll-up pagination). */
|
||||||
|
loadOlder: () => Promise<void>;
|
||||||
|
/** Append a newer page while detached; reattaches at the tail. */
|
||||||
|
loadNewer: () => Promise<void>;
|
||||||
|
/** Replace the window with the live tail (jump to newest). */
|
||||||
|
reloadTail: () => Promise<void>;
|
||||||
edit: (messageId: string, content: string) => Promise<void>;
|
edit: (messageId: string, content: string) => Promise<void>;
|
||||||
/** Delete for everyone (sender, or group owner as moderation). */
|
/** Delete for everyone (sender, or group owner as moderation). */
|
||||||
remove: (messageId: string) => Promise<void>;
|
remove: (messageId: string) => Promise<void>;
|
||||||
|
|
@ -63,6 +106,46 @@ const mimeToKind = (mime: string): MediaKind => {
|
||||||
return 'file';
|
return 'file';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Media dimensions are captured at send time and stored in the MediaRef so
|
||||||
|
// every client can reserve the exact box before the file loads — this is what
|
||||||
|
// keeps chat layout deterministic (stable scroll positions).
|
||||||
|
const probeImage = async (file: File): Promise<{ width: number; height: number } | null> => {
|
||||||
|
try {
|
||||||
|
const bitmap = await createImageBitmap(file);
|
||||||
|
const dims = { width: bitmap.width, height: bitmap.height };
|
||||||
|
bitmap.close();
|
||||||
|
return dims;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const probeVideo = (
|
||||||
|
file: File,
|
||||||
|
): Promise<{ width: number; height: number; durationSec: number } | null> =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.preload = 'metadata';
|
||||||
|
video.onloadedmetadata = () => {
|
||||||
|
const result =
|
||||||
|
video.videoWidth > 0
|
||||||
|
? {
|
||||||
|
width: video.videoWidth,
|
||||||
|
height: video.videoHeight,
|
||||||
|
durationSec: Number.isFinite(video.duration) ? video.duration : 0,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
video.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(null);
|
||||||
|
};
|
||||||
|
video.src = url;
|
||||||
|
});
|
||||||
|
|
||||||
const hasType = (data: unknown): data is { type: string } =>
|
const hasType = (data: unknown): data is { type: string } =>
|
||||||
typeof data === 'object' && data !== null && 'type' in data && typeof data.type === 'string';
|
typeof data === 'object' && data !== null && 'type' in data && typeof data.type === 'string';
|
||||||
|
|
||||||
|
|
@ -77,9 +160,26 @@ const isHiddenEvent = (d: unknown): d is MessageHiddenEvent =>
|
||||||
hasType(d) && d.type === EventType.MessageHidden;
|
hasType(d) && d.type === EventType.MessageHidden;
|
||||||
const isClearedEvent = (d: unknown): d is ConversationClearedEvent =>
|
const isClearedEvent = (d: unknown): d is ConversationClearedEvent =>
|
||||||
hasType(d) && d.type === EventType.ConversationCleared;
|
hasType(d) && d.type === EventType.ConversationCleared;
|
||||||
|
const isPinEvent = (d: unknown): d is { type: string; conversationId: string } =>
|
||||||
|
hasType(d) && (d.type === EventType.MessagePin || d.type === EventType.MessageUnpin);
|
||||||
const isTypingEvent = (d: unknown): d is TypingEvent =>
|
const isTypingEvent = (d: unknown): d is TypingEvent =>
|
||||||
hasType(d) && (d.type === EventType.TypingStart || d.type === EventType.TypingStop);
|
hasType(d) && (d.type === EventType.TypingStart || d.type === EventType.TypingStop);
|
||||||
|
|
||||||
|
// Everything needed to (re)run an upload without re-asking the user for the
|
||||||
|
// file: retry after a failed transfer re-presigns and re-uploads; retry after
|
||||||
|
// a failed send reuses the already-uploaded objectKey and only re-sends.
|
||||||
|
interface UploadJob {
|
||||||
|
file: File;
|
||||||
|
mime: string;
|
||||||
|
media: MediaRef;
|
||||||
|
caption: string;
|
||||||
|
conversationId: string;
|
||||||
|
/** Set once the bytes are in MinIO — a later retry skips the transfer. */
|
||||||
|
objectKey: string | null;
|
||||||
|
controller: AbortController | null;
|
||||||
|
previewUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
const applyReaction = (
|
const applyReaction = (
|
||||||
reactions: ReactionSummary[],
|
reactions: ReactionSummary[],
|
||||||
emoji: string,
|
emoji: string,
|
||||||
|
|
@ -110,10 +210,29 @@ export const useConversationMessages = (
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [typingUserIds, setTypingUserIds] = useState<string[]>([]);
|
const [typingUserIds, setTypingUserIds] = useState<string[]>([]);
|
||||||
const [peerReadSeq, setPeerReadSeq] = useState(0);
|
const [peerReadSeq, setPeerReadSeq] = useState(0);
|
||||||
|
const [pinned, setPinned] = useState<Message[]>([]);
|
||||||
|
const [detached, setDetached] = useState(false);
|
||||||
|
const [hasMoreUp, setHasMoreUp] = useState(true);
|
||||||
|
const messagesRef = useRef<Message[]>([]);
|
||||||
|
messagesRef.current = messages;
|
||||||
|
// Highest confirmed seq we know exists (live events keep it fresh even while
|
||||||
|
// the window is detached and not merging them).
|
||||||
|
const latestSeqRef = useRef(0);
|
||||||
|
const detachedRef = useRef(false);
|
||||||
|
detachedRef.current = detached;
|
||||||
|
const pagingRef = useRef(false);
|
||||||
const typingTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
const typingTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||||
const lastTypingSent = useRef(0);
|
const lastTypingSent = useRef(0);
|
||||||
|
// Uploads outlive conversation switches (the transfer keeps running in the
|
||||||
|
// background), so entries are kept globally and filtered per conversation.
|
||||||
|
const [allUploads, setAllUploads] = useState<PendingUpload[]>([]);
|
||||||
|
const allUploadsRef = useRef<PendingUpload[]>([]);
|
||||||
|
allUploadsRef.current = allUploads;
|
||||||
|
const uploadJobs = useRef<Map<string, UploadJob>>(new Map());
|
||||||
|
|
||||||
const conversationId = conversation.id;
|
const conversationId = conversation.id;
|
||||||
|
const conversationIdRef = useRef(conversationId);
|
||||||
|
conversationIdRef.current = conversationId;
|
||||||
// Groups deliver on the conversation channel; DMs on the personal channel.
|
// Groups deliver on the conversation channel; DMs on the personal channel.
|
||||||
// Per-user view-state events (hide/clear) always arrive on the personal one,
|
// Per-user view-state events (hide/clear) always arrive on the personal one,
|
||||||
// so that subscription is unconditional.
|
// so that subscription is unconditional.
|
||||||
|
|
@ -125,8 +244,26 @@ export const useConversationMessages = (
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setTypingUserIds([]);
|
setTypingUserIds([]);
|
||||||
setPeerReadSeq(0);
|
setPeerReadSeq(0);
|
||||||
|
setPinned([]);
|
||||||
|
setDetached(false);
|
||||||
|
setHasMoreUp(true);
|
||||||
|
latestSeqRef.current = 0;
|
||||||
|
pagingRef.current = false;
|
||||||
const timers = typingTimers.current;
|
const timers = typingTimers.current;
|
||||||
|
|
||||||
|
const refreshPinned = (): void => {
|
||||||
|
void listPinned(apiConfig, conversationId)
|
||||||
|
.then((list) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setPinned(list);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* not permitted / gone */
|
||||||
|
});
|
||||||
|
};
|
||||||
|
refreshPinned();
|
||||||
|
|
||||||
const clearTyping = (userId: string): void => {
|
const clearTyping = (userId: string): void => {
|
||||||
setTypingUserIds((prev) => prev.filter((id) => id !== userId));
|
setTypingUserIds((prev) => prev.filter((id) => id !== userId));
|
||||||
};
|
};
|
||||||
|
|
@ -137,7 +274,22 @@ export const useConversationMessages = (
|
||||||
if (isMessageEvent(data)) {
|
if (isMessageEvent(data)) {
|
||||||
if (data.message.conversationId === conversationId) {
|
if (data.message.conversationId === conversationId) {
|
||||||
const { message } = data;
|
const { message } = data;
|
||||||
setMessages((prev) => mergeMessages(prev, [message]));
|
if (message.seq !== OPTIMISTIC_SEQ) {
|
||||||
|
latestSeqRef.current = Math.max(latestSeqRef.current, message.seq);
|
||||||
|
}
|
||||||
|
if (data.type === EventType.MessageEdit) {
|
||||||
|
// Edits apply only to messages inside the loaded window — merging
|
||||||
|
// one from outside would punch a hole into the contiguous window.
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.some((m) => m.id === message.id) ? mergeMessages(prev, [message]) : prev,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// While detached (viewing an old context) new messages aren't merged;
|
||||||
|
// they're fetched by loadNewer/reloadTail when returning to the tail.
|
||||||
|
if (!detachedRef.current) {
|
||||||
|
setMessages((prev) => mergeMessages(prev, [message]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -189,6 +341,12 @@ export const useConversationMessages = (
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isPinEvent(data)) {
|
||||||
|
if (data.conversationId === conversationId) {
|
||||||
|
refreshPinned();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isTypingEvent(data)) {
|
if (isTypingEvent(data)) {
|
||||||
if (data.conversationId !== conversationId || data.userId === me.id) {
|
if (data.conversationId !== conversationId || data.userId === me.id) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -223,6 +381,13 @@ export const useConversationMessages = (
|
||||||
try {
|
try {
|
||||||
const history = await getHistory(apiConfig, conversationId, { limit: 50 });
|
const history = await getHistory(apiConfig, conversationId, { limit: 50 });
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
latestSeqRef.current = history.reduce(
|
||||||
|
(max, m) => (m.seq === OPTIMISTIC_SEQ ? max : Math.max(max, m.seq)),
|
||||||
|
latestSeqRef.current,
|
||||||
|
);
|
||||||
|
if (history.length < 50) {
|
||||||
|
setHasMoreUp(false);
|
||||||
|
}
|
||||||
setMessages((prev) => mergeMessages(prev, history));
|
setMessages((prev) => mergeMessages(prev, history));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -255,8 +420,103 @@ export const useConversationMessages = (
|
||||||
}
|
}
|
||||||
}, [messages, conversationId]);
|
}, [messages, conversationId]);
|
||||||
|
|
||||||
|
// ---- windowed history operations ----
|
||||||
|
|
||||||
|
const reloadTail = useCallback(async (): Promise<void> => {
|
||||||
|
const history = await getHistory(apiConfig, conversationId, { limit: 50 });
|
||||||
|
latestSeqRef.current = history.reduce(
|
||||||
|
(max, m) => (m.seq === OPTIMISTIC_SEQ ? max : Math.max(max, m.seq)),
|
||||||
|
latestSeqRef.current,
|
||||||
|
);
|
||||||
|
setHasMoreUp(history.length >= 50);
|
||||||
|
setDetached(false);
|
||||||
|
detachedRef.current = false;
|
||||||
|
setMessages(mergeMessages([], history));
|
||||||
|
}, [conversationId]);
|
||||||
|
|
||||||
|
const jumpTo = useCallback(
|
||||||
|
async (messageId: string): Promise<boolean> => {
|
||||||
|
if (messagesRef.current.some((m) => m.id === messageId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const context = await getMessageContext(apiConfig, conversationId, messageId, 60);
|
||||||
|
if (!context.some((m) => m.id === messageId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const maxSeq = context.reduce((max, m) => Math.max(max, m.seq), 0);
|
||||||
|
latestSeqRef.current = Math.max(latestSeqRef.current, maxSeq);
|
||||||
|
const nowDetached = maxSeq < latestSeqRef.current;
|
||||||
|
setMessages(mergeMessages([], context));
|
||||||
|
setDetached(nowDetached);
|
||||||
|
detachedRef.current = nowDetached;
|
||||||
|
setHasMoreUp(true);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[conversationId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadOlder = useCallback(async (): Promise<void> => {
|
||||||
|
if (pagingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const real = messagesRef.current.filter((m) => m.seq !== OPTIMISTIC_SEQ);
|
||||||
|
const first = real[0];
|
||||||
|
if (first === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pagingRef.current = true;
|
||||||
|
try {
|
||||||
|
const batch = await getHistory(apiConfig, conversationId, { before: first.seq, limit: 50 });
|
||||||
|
if (batch.length < 50) {
|
||||||
|
setHasMoreUp(false);
|
||||||
|
}
|
||||||
|
if (batch.length > 0) {
|
||||||
|
setMessages((prev) => mergeMessages(prev, batch));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pagingRef.current = false;
|
||||||
|
}
|
||||||
|
}, [conversationId]);
|
||||||
|
|
||||||
|
const loadNewer = useCallback(async (): Promise<void> => {
|
||||||
|
if (pagingRef.current || !detachedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const real = messagesRef.current.filter((m) => m.seq !== OPTIMISTIC_SEQ);
|
||||||
|
const last = real[real.length - 1];
|
||||||
|
if (last === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pagingRef.current = true;
|
||||||
|
try {
|
||||||
|
const batch = await getHistory(apiConfig, conversationId, { after: last.seq, limit: 50 });
|
||||||
|
if (batch.length > 0) {
|
||||||
|
latestSeqRef.current = batch.reduce(
|
||||||
|
(max, m) => Math.max(max, m.seq),
|
||||||
|
latestSeqRef.current,
|
||||||
|
);
|
||||||
|
setMessages((prev) => mergeMessages(prev, batch));
|
||||||
|
}
|
||||||
|
if (batch.length < 50) {
|
||||||
|
// Caught up with the live tail — reattach (live merges resume).
|
||||||
|
setDetached(false);
|
||||||
|
detachedRef.current = false;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pagingRef.current = false;
|
||||||
|
}
|
||||||
|
}, [conversationId]);
|
||||||
|
|
||||||
const send = useCallback(
|
const send = useCallback(
|
||||||
async (content: string): Promise<void> => {
|
async (content: string, replyToId?: string): Promise<void> => {
|
||||||
|
// Sending from a jumped-to context returns to the live tail first.
|
||||||
|
if (detachedRef.current) {
|
||||||
|
await reloadTail();
|
||||||
|
}
|
||||||
const clientMsgId = crypto.randomUUID();
|
const clientMsgId = crypto.randomUUID();
|
||||||
const optimistic: Message = {
|
const optimistic: Message = {
|
||||||
id: `optimistic:${clientMsgId}`,
|
id: `optimistic:${clientMsgId}`,
|
||||||
|
|
@ -273,31 +533,166 @@ export const useConversationMessages = (
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
reactions: [],
|
reactions: [],
|
||||||
media: null,
|
media: null,
|
||||||
|
replyTo: null,
|
||||||
|
forwarded: false,
|
||||||
|
forwardedFrom: null,
|
||||||
};
|
};
|
||||||
setMessages((prev) => mergeMessages(prev, [optimistic]));
|
setMessages((prev) => mergeMessages(prev, [optimistic]));
|
||||||
const confirmed = await sendMessage(apiConfig, conversationId, { content, clientMsgId });
|
|
||||||
setMessages((prev) => mergeMessages(prev, [confirmed]));
|
|
||||||
},
|
|
||||||
[conversationId, me],
|
|
||||||
);
|
|
||||||
|
|
||||||
const sendMedia = useCallback(
|
|
||||||
async (file: File, caption: string, kindOverride?: MediaKind): Promise<void> => {
|
|
||||||
const mime = file.type.length > 0 ? file.type : 'application/octet-stream';
|
|
||||||
const kind = kindOverride ?? mimeToKind(mime);
|
|
||||||
const { uploadUrl, objectKey } = await getUploadUrl(apiConfig, { kind, mime, size: file.size });
|
|
||||||
await uploadToUrl(uploadUrl, file, mime);
|
|
||||||
const media: MediaRef = { kind, mime, size: file.size, name: file.name };
|
|
||||||
const clientMsgId = crypto.randomUUID();
|
|
||||||
const confirmed = await sendMessage(apiConfig, conversationId, {
|
const confirmed = await sendMessage(apiConfig, conversationId, {
|
||||||
content: caption,
|
content,
|
||||||
clientMsgId,
|
clientMsgId,
|
||||||
mediaKey: objectKey,
|
...(replyToId !== undefined ? { replyToId } : {}),
|
||||||
media,
|
|
||||||
});
|
});
|
||||||
setMessages((prev) => mergeMessages(prev, [confirmed]));
|
setMessages((prev) => mergeMessages(prev, [confirmed]));
|
||||||
},
|
},
|
||||||
[conversationId],
|
[conversationId, me, reloadTail],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- media uploads ----
|
||||||
|
|
||||||
|
// Run (or resume) the presign → transfer → send pipeline for one upload.
|
||||||
|
// Any failure parks the entry in 'error' for the user to retry or discard;
|
||||||
|
// nothing is ever dropped silently.
|
||||||
|
const runUpload = useCallback(async (uploadId: string): Promise<void> => {
|
||||||
|
const job = uploadJobs.current.get(uploadId);
|
||||||
|
if (job === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const patch = (partial: Partial<PendingUpload>): void => {
|
||||||
|
setAllUploads((prev) => prev.map((u) => (u.id === uploadId ? { ...u, ...partial } : u)));
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
let objectKey = job.objectKey;
|
||||||
|
if (objectKey === null) {
|
||||||
|
patch({ status: 'uploading', progress: 0 });
|
||||||
|
// Presigned URLs expire — every (re)attempt asks for a fresh one.
|
||||||
|
const target = await getUploadUrl(apiConfig, {
|
||||||
|
kind: job.media.kind,
|
||||||
|
mime: job.mime,
|
||||||
|
size: job.file.size,
|
||||||
|
});
|
||||||
|
const controller = new AbortController();
|
||||||
|
job.controller = controller;
|
||||||
|
await uploadToUrl(target.uploadUrl, job.file, job.mime, {
|
||||||
|
signal: controller.signal,
|
||||||
|
onProgress: (loaded, total) => {
|
||||||
|
patch({ progress: total > 0 ? loaded / total : 0 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
job.controller = null;
|
||||||
|
objectKey = target.objectKey;
|
||||||
|
job.objectKey = objectKey;
|
||||||
|
}
|
||||||
|
patch({ status: 'sending', progress: 1 });
|
||||||
|
const confirmed = await sendMessage(apiConfig, job.conversationId, {
|
||||||
|
content: job.caption,
|
||||||
|
clientMsgId: uploadId,
|
||||||
|
mediaKey: objectKey,
|
||||||
|
media: job.media,
|
||||||
|
});
|
||||||
|
uploadJobs.current.delete(uploadId);
|
||||||
|
if (job.previewUrl !== null) {
|
||||||
|
URL.revokeObjectURL(job.previewUrl);
|
||||||
|
}
|
||||||
|
setAllUploads((prev) => prev.filter((u) => u.id !== uploadId));
|
||||||
|
// Merge only while the user still looks at this conversation's live tail;
|
||||||
|
// otherwise history load / the realtime channel delivers it.
|
||||||
|
if (job.conversationId === conversationIdRef.current && !detachedRef.current) {
|
||||||
|
setMessages((prev) => mergeMessages(prev, [confirmed]));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
job.controller = null;
|
||||||
|
// A cancelled upload removed its job already — don't resurrect the entry.
|
||||||
|
if (uploadJobs.current.has(uploadId)) {
|
||||||
|
patch({ status: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const retryUpload = useCallback(
|
||||||
|
(uploadId: string): void => {
|
||||||
|
const entry = allUploadsRef.current.find((u) => u.id === uploadId);
|
||||||
|
if (entry?.status === 'error') {
|
||||||
|
void runUpload(uploadId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[runUpload],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelUpload = useCallback((uploadId: string): void => {
|
||||||
|
const job = uploadJobs.current.get(uploadId);
|
||||||
|
if (job === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadJobs.current.delete(uploadId);
|
||||||
|
job.controller?.abort();
|
||||||
|
if (job.previewUrl !== null) {
|
||||||
|
URL.revokeObjectURL(job.previewUrl);
|
||||||
|
}
|
||||||
|
setAllUploads((prev) => prev.filter((u) => u.id !== uploadId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const uploads = useMemo(
|
||||||
|
() => allUploads.filter((u) => u.conversationId === conversationId),
|
||||||
|
[allUploads, conversationId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendMedia = useCallback(
|
||||||
|
async (
|
||||||
|
file: File,
|
||||||
|
caption: string,
|
||||||
|
kindOverride?: MediaKind,
|
||||||
|
durationSec?: number,
|
||||||
|
): Promise<void> => {
|
||||||
|
// Sending from a jumped-to context returns to the live tail first.
|
||||||
|
if (detachedRef.current) {
|
||||||
|
await reloadTail();
|
||||||
|
}
|
||||||
|
const mime = file.type.length > 0 ? file.type : 'application/octet-stream';
|
||||||
|
const kind = kindOverride ?? mimeToKind(mime);
|
||||||
|
const media: MediaRef = { kind, mime, size: file.size, name: file.name };
|
||||||
|
// Capture intrinsic dimensions/duration up front (deterministic layout).
|
||||||
|
if (kind === 'image') {
|
||||||
|
const dims = await probeImage(file);
|
||||||
|
if (dims !== null) {
|
||||||
|
media.width = dims.width;
|
||||||
|
media.height = dims.height;
|
||||||
|
}
|
||||||
|
} else if (kind === 'video' || kind === 'video_note') {
|
||||||
|
const dims = await probeVideo(file);
|
||||||
|
if (dims !== null) {
|
||||||
|
media.width = dims.width;
|
||||||
|
media.height = dims.height;
|
||||||
|
if (dims.durationSec > 0) {
|
||||||
|
media.durationSec = dims.durationSec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (durationSec !== undefined && durationSec > 0) {
|
||||||
|
media.durationSec = durationSec;
|
||||||
|
}
|
||||||
|
const uploadId = crypto.randomUUID();
|
||||||
|
const previewUrl =
|
||||||
|
kind === 'image' || kind === 'video' || kind === 'video_note'
|
||||||
|
? URL.createObjectURL(file)
|
||||||
|
: null;
|
||||||
|
uploadJobs.current.set(uploadId, {
|
||||||
|
file,
|
||||||
|
mime,
|
||||||
|
media,
|
||||||
|
caption,
|
||||||
|
conversationId,
|
||||||
|
objectKey: null,
|
||||||
|
controller: null,
|
||||||
|
previewUrl,
|
||||||
|
});
|
||||||
|
setAllUploads((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ id: uploadId, conversationId, media, caption, previewUrl, progress: 0, status: 'uploading' },
|
||||||
|
]);
|
||||||
|
await runUpload(uploadId);
|
||||||
|
},
|
||||||
|
[conversationId, reloadTail, runUpload],
|
||||||
);
|
);
|
||||||
|
|
||||||
const edit = useCallback(
|
const edit = useCallback(
|
||||||
|
|
@ -351,8 +746,18 @@ export const useConversationMessages = (
|
||||||
loading,
|
loading,
|
||||||
typingUserIds,
|
typingUserIds,
|
||||||
peerReadSeq,
|
peerReadSeq,
|
||||||
|
pinned,
|
||||||
|
detached,
|
||||||
|
hasMoreUp,
|
||||||
|
jumpTo,
|
||||||
|
loadOlder,
|
||||||
|
loadNewer,
|
||||||
|
reloadTail,
|
||||||
send,
|
send,
|
||||||
sendMedia,
|
sendMedia,
|
||||||
|
uploads,
|
||||||
|
retryUpload,
|
||||||
|
cancelUpload,
|
||||||
edit,
|
edit,
|
||||||
remove,
|
remove,
|
||||||
hide,
|
hide,
|
||||||
|
|
|
||||||
16
packages/web/src/features/messaging/playback.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
// One media element at a time (Telegram behavior): starting any voice note,
|
||||||
|
// video message, or viewer video pauses whatever was playing before — across
|
||||||
|
// kinds, not just within one. Every playable component reports its element
|
||||||
|
// from its `play` event; the module pauses the previous claimant.
|
||||||
|
//
|
||||||
|
// Module-level singleton on purpose: playback exclusivity is a per-tab
|
||||||
|
// invariant, not per-conversation state. Pausing a detached (unmounted)
|
||||||
|
// element is a harmless no-op, so claimants never need to deregister.
|
||||||
|
let current: HTMLMediaElement | null = null;
|
||||||
|
|
||||||
|
export const claimPlayback = (element: HTMLMediaElement): void => {
|
||||||
|
if (current !== null && current !== element && !current.paused) {
|
||||||
|
current.pause();
|
||||||
|
}
|
||||||
|
current = element;
|
||||||
|
};
|
||||||
|
|
@ -16,9 +16,11 @@ export interface Recorder {
|
||||||
}
|
}
|
||||||
|
|
||||||
// In-app voice/video message capture via MediaRecorder. Produces a webm File and
|
// In-app voice/video message capture via MediaRecorder. Produces a webm File and
|
||||||
// hands it to `onComplete` with the record kind, which uploads + sends it as a
|
// hands it to `onComplete` with the record kind and duration (MediaRecorder
|
||||||
// media message (video → circular note, voice → waveform).
|
// WebM carries no duration metadata, so the wall-clock time is authoritative).
|
||||||
export const useRecorder = (onComplete: (file: File, kind: RecordKind) => void): Recorder => {
|
export const useRecorder = (
|
||||||
|
onComplete: (file: File, kind: RecordKind, durationSec: number) => void,
|
||||||
|
): Recorder => {
|
||||||
const [recording, setRecording] = useState<RecordKind | null>(null);
|
const [recording, setRecording] = useState<RecordKind | null>(null);
|
||||||
const [elapsedMs, setElapsedMs] = useState(0);
|
const [elapsedMs, setElapsedMs] = useState(0);
|
||||||
const [previewStream, setPreviewStream] = useState<MediaStream | null>(null);
|
const [previewStream, setPreviewStream] = useState<MediaStream | null>(null);
|
||||||
|
|
@ -29,6 +31,7 @@ export const useRecorder = (onComplete: (file: File, kind: RecordKind) => void):
|
||||||
const kindRef = useRef<RecordKind | null>(null);
|
const kindRef = useRef<RecordKind | null>(null);
|
||||||
const cancelledRef = useRef(false);
|
const cancelledRef = useRef(false);
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const startedAtRef = useRef(0);
|
||||||
const onCompleteRef = useRef(onComplete);
|
const onCompleteRef = useRef(onComplete);
|
||||||
onCompleteRef.current = onComplete;
|
onCompleteRef.current = onComplete;
|
||||||
|
|
||||||
|
|
@ -76,7 +79,12 @@ export const useRecorder = (onComplete: (file: File, kind: RecordKind) => void):
|
||||||
const isVideo = kindRef.current === 'video';
|
const isVideo = kindRef.current === 'video';
|
||||||
const type = isVideo ? 'video/webm' : 'audio/webm';
|
const type = isVideo ? 'video/webm' : 'audio/webm';
|
||||||
const name = isVideo ? 'video-message.webm' : 'voice-message.webm';
|
const name = isVideo ? 'video-message.webm' : 'voice-message.webm';
|
||||||
onCompleteRef.current(new File(chunks, name, { type }), isVideo ? 'video' : 'voice');
|
const durationSec = Math.max(0, (Date.now() - startedAtRef.current) / 1000);
|
||||||
|
onCompleteRef.current(
|
||||||
|
new File(chunks, name, { type }),
|
||||||
|
isVideo ? 'video' : 'voice',
|
||||||
|
durationSec,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
recorderRef.current = null;
|
recorderRef.current = null;
|
||||||
setRecording(null);
|
setRecording(null);
|
||||||
|
|
@ -87,6 +95,7 @@ export const useRecorder = (onComplete: (file: File, kind: RecordKind) => void):
|
||||||
recorder.start();
|
recorder.start();
|
||||||
setRecording(kind);
|
setRecording(kind);
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
startedAtRef.current = startedAt;
|
||||||
timerRef.current = setInterval(() => {
|
timerRef.current = setInterval(() => {
|
||||||
setElapsedMs(Date.now() - startedAt);
|
setElapsedMs(Date.now() - startedAt);
|
||||||
}, 200);
|
}, 200);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { apiConfig } from '../../../shared/api';
|
||||||
import { FileIcon, DownloadIcon, PlayIcon } from '../../../shared/ui';
|
import { FileIcon, DownloadIcon, PlayIcon } from '../../../shared/ui';
|
||||||
import { VoiceMessage } from './VoiceMessage';
|
import { VoiceMessage } from './VoiceMessage';
|
||||||
import { VideoMessage } from './VideoMessage';
|
import { VideoMessage } from './VideoMessage';
|
||||||
|
import { VideoThumb } from './VideoThumb';
|
||||||
import { useMediaViewer } from './MediaViewer';
|
import { useMediaViewer } from './MediaViewer';
|
||||||
|
|
||||||
const formatSize = (bytes: number): string => {
|
const formatSize = (bytes: number): string => {
|
||||||
|
|
@ -14,6 +15,31 @@ const formatSize = (bytes: number): string => {
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Deterministic media boxes: when the MediaRef carries intrinsic dimensions we
|
||||||
|
// compute the display box up front (nothing reflows when the file loads), which
|
||||||
|
// is what keeps scroll positions stable. Legacy media without dimensions gets a
|
||||||
|
// fixed cover-cropped box for the same reason.
|
||||||
|
interface BoxStyle {
|
||||||
|
width: string;
|
||||||
|
aspectRatio: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fitBox = (
|
||||||
|
media: { width?: number; height?: number },
|
||||||
|
maxWidth: number,
|
||||||
|
maxHeight: number,
|
||||||
|
): BoxStyle | null => {
|
||||||
|
const { width, height } = media;
|
||||||
|
if (width === undefined || height === undefined || width <= 0 || height <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const scale = Math.min(maxWidth / width, maxHeight / height, 1);
|
||||||
|
return {
|
||||||
|
width: `${String(Math.round(width * scale))}px`,
|
||||||
|
aspectRatio: `${String(width)} / ${String(height)}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
message: Message;
|
message: Message;
|
||||||
|
|
@ -62,15 +88,17 @@ export const MediaMessage = ({ conversationId, message, mine }: Props): ReactEle
|
||||||
}
|
}
|
||||||
|
|
||||||
if (media.kind === 'video') {
|
if (media.kind === 'video') {
|
||||||
|
const box = fitBox(media, 340, 260);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="media-video-frame"
|
className={box !== null ? 'media-video-frame' : 'media-video-frame media-box-legacy'}
|
||||||
|
style={box ?? undefined}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
viewer.open({ type: 'video', url, name: media.name });
|
viewer.open({ type: 'video', url, name: media.name });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<video src={url} className="media-video" preload="metadata" muted />
|
<VideoThumb url={url} className="media-video media-fill" />
|
||||||
<span className="media-play-overlay">
|
<span className="media-play-overlay">
|
||||||
<PlayIcon size={24} />
|
<PlayIcon size={24} />
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -79,15 +107,17 @@ export const MediaMessage = ({ conversationId, message, mine }: Props): ReactEle
|
||||||
}
|
}
|
||||||
|
|
||||||
if (media.kind === 'image') {
|
if (media.kind === 'image') {
|
||||||
|
const box = fitBox(media, 300, 340);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="media-image-btn"
|
className={box !== null ? 'media-image-btn' : 'media-image-btn media-box-legacy'}
|
||||||
|
style={box ?? undefined}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
viewer.open({ type: 'image', url, name: media.name });
|
viewer.open({ type: 'image', url, name: media.name });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img src={url} alt={media.name} className="media-img" />
|
<img src={url} alt={media.name} className="media-img media-fill" />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
|
||||||
import type { ReactElement, ReactNode } from 'react';
|
import type { ReactElement, ReactNode } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { CloseIcon, DownloadIcon } from '../../../shared/ui';
|
import { CloseIcon, DownloadIcon } from '../../../shared/ui';
|
||||||
|
import { claimPlayback } from '../playback';
|
||||||
|
|
||||||
export interface ViewerItem {
|
export interface ViewerItem {
|
||||||
type: 'image' | 'video';
|
type: 'image' | 'video';
|
||||||
|
|
@ -145,7 +146,15 @@ const Overlay = ({ item, onClose }: { item: ViewerItem; onClose: () => void }):
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<video src={item.url} className="viewer-video" controls autoPlay />
|
<video
|
||||||
|
src={item.url}
|
||||||
|
className="viewer-video"
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
onPlay={(event) => {
|
||||||
|
claimPlayback(event.currentTarget);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
145
packages/web/src/features/messaging/ui/UploadBubble.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import type { PendingUpload } from '../model';
|
||||||
|
import {
|
||||||
|
CloseIcon,
|
||||||
|
FileIcon,
|
||||||
|
ImageIcon,
|
||||||
|
MicIcon,
|
||||||
|
RefreshIcon,
|
||||||
|
VideoIcon,
|
||||||
|
} from '../../../shared/ui';
|
||||||
|
|
||||||
|
const RING_RADIUS = 15.5;
|
||||||
|
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
|
||||||
|
|
||||||
|
const formatSize = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) return `${String(bytes)} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProgressRing = ({ progress }: { progress: number }): ReactElement => {
|
||||||
|
const clamped = Math.min(1, Math.max(0, progress));
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 40 40" className="upload-ring" aria-hidden="true">
|
||||||
|
<circle className="upload-ring-track" cx="20" cy="20" r={RING_RADIUS} />
|
||||||
|
<circle
|
||||||
|
className="upload-ring-fill"
|
||||||
|
cx="20"
|
||||||
|
cy="20"
|
||||||
|
r={RING_RADIUS}
|
||||||
|
strokeDasharray={RING_CIRCUMFERENCE}
|
||||||
|
strokeDashoffset={RING_CIRCUMFERENCE * (1 - clamped)}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const kindMeta = (upload: PendingUpload): { label: string; icon: ReactElement } => {
|
||||||
|
switch (upload.media.kind) {
|
||||||
|
case 'voice':
|
||||||
|
return { label: 'Voice message', icon: <MicIcon size={17} /> };
|
||||||
|
case 'video_note':
|
||||||
|
return { label: 'Video message', icon: <VideoIcon size={17} /> };
|
||||||
|
case 'video':
|
||||||
|
return { label: upload.media.name, icon: <VideoIcon size={17} /> };
|
||||||
|
case 'image':
|
||||||
|
return { label: upload.media.name, icon: <ImageIcon size={17} /> };
|
||||||
|
default:
|
||||||
|
return { label: upload.media.name, icon: <FileIcon size={17} /> };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
upload: PendingUpload;
|
||||||
|
onRetry: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An outgoing bubble body for a media send that hasn't completed yet:
|
||||||
|
// percentage ring with cancel inside while the bytes transfer (a presigned PUT
|
||||||
|
// can't pause/resume, so cancel is the one live control), spinner while the
|
||||||
|
// message is registered, and Retry/Discard once anything fails.
|
||||||
|
export const UploadBubble = ({ upload, onRetry, onCancel }: Props): ReactElement => {
|
||||||
|
const failed = upload.status === 'error';
|
||||||
|
const statusText = failed
|
||||||
|
? 'Upload failed'
|
||||||
|
: upload.status === 'sending'
|
||||||
|
? 'Sending…'
|
||||||
|
: `${String(Math.round(upload.progress * 100))}%`;
|
||||||
|
|
||||||
|
const control = failed ? (
|
||||||
|
<span className="upload-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn upload-action"
|
||||||
|
title="Retry upload"
|
||||||
|
aria-label="Retry upload"
|
||||||
|
onClick={onRetry}
|
||||||
|
>
|
||||||
|
<RefreshIcon size={17} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn upload-action"
|
||||||
|
title="Discard"
|
||||||
|
aria-label="Discard upload"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
<CloseIcon size={17} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
) : upload.status === 'sending' ? (
|
||||||
|
<span className="upload-ring-btn is-busy">
|
||||||
|
<span className="upload-spinner" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="upload-ring-btn"
|
||||||
|
title="Cancel upload"
|
||||||
|
aria-label="Cancel upload"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
<ProgressRing progress={upload.progress} />
|
||||||
|
<CloseIcon size={13} className="upload-ring-x" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (upload.previewUrl !== null) {
|
||||||
|
return (
|
||||||
|
<div className={failed ? 'upload-media is-failed' : 'upload-media'}>
|
||||||
|
{upload.media.kind === 'image' ? (
|
||||||
|
<img src={upload.previewUrl} alt="" className="upload-media-preview" />
|
||||||
|
) : (
|
||||||
|
<video
|
||||||
|
src={upload.previewUrl}
|
||||||
|
className="upload-media-preview"
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="upload-media-shade" aria-hidden="true" />
|
||||||
|
<span className="upload-media-overlay">
|
||||||
|
{control}
|
||||||
|
<span className="upload-status">{statusText}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { label, icon } = kindMeta(upload);
|
||||||
|
return (
|
||||||
|
<div className={failed ? 'upload-row is-failed' : 'upload-row'}>
|
||||||
|
{control}
|
||||||
|
<span className="upload-row-meta">
|
||||||
|
<span className="upload-row-name">{label}</span>
|
||||||
|
<span className="upload-row-sub">
|
||||||
|
{formatSize(upload.media.size)} · {statusText}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="upload-row-kind">{icon}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import { PlayIcon } from '../../../shared/ui';
|
import { PlayIcon } from '../../../shared/ui';
|
||||||
|
import { claimPlayback } from '../playback';
|
||||||
|
import { onPrimeMetadata } from './VideoThumb';
|
||||||
|
|
||||||
const formatDuration = (seconds: number): string => {
|
const formatDuration = (seconds: number): string => {
|
||||||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||||
|
|
@ -17,9 +19,11 @@ interface Props {
|
||||||
durationSec: number | undefined;
|
durationSec: number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Circular "video message" — tap to play with sound, tap again to pause.
|
// Circular "video message" — idles on a real preview frame; tap to play with
|
||||||
|
// sound, tap again to pause.
|
||||||
export const VideoMessage = ({ url, durationSec }: Props): ReactElement => {
|
export const VideoMessage = ({ url, durationSec }: Props): ReactElement => {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const startedRef = useRef(false);
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const [remaining, setRemaining] = useState(durationSec ?? 0);
|
const [remaining, setRemaining] = useState(durationSec ?? 0);
|
||||||
|
|
||||||
|
|
@ -29,6 +33,11 @@ export const VideoMessage = ({ url, durationSec }: Props): ReactElement => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (video.paused) {
|
if (video.paused) {
|
||||||
|
if (!startedRef.current) {
|
||||||
|
// The idle element sits on the preview frame; play from the start.
|
||||||
|
startedRef.current = true;
|
||||||
|
video.currentTime = 0;
|
||||||
|
}
|
||||||
void video.play();
|
void video.play();
|
||||||
} else {
|
} else {
|
||||||
video.pause();
|
video.pause();
|
||||||
|
|
@ -42,7 +51,10 @@ export const VideoMessage = ({ url, durationSec }: Props): ReactElement => {
|
||||||
src={url}
|
src={url}
|
||||||
className="round-video-el"
|
className="round-video-el"
|
||||||
playsInline
|
playsInline
|
||||||
onPlay={() => {
|
preload="metadata"
|
||||||
|
onLoadedMetadata={onPrimeMetadata}
|
||||||
|
onPlay={(event) => {
|
||||||
|
claimPlayback(event.currentTarget);
|
||||||
setPlaying(true);
|
setPlaying(true);
|
||||||
}}
|
}}
|
||||||
onPause={() => {
|
onPause={() => {
|
||||||
|
|
@ -50,7 +62,10 @@ export const VideoMessage = ({ url, durationSec }: Props): ReactElement => {
|
||||||
}}
|
}}
|
||||||
onTimeUpdate={(event) => {
|
onTimeUpdate={(event) => {
|
||||||
const video = event.currentTarget;
|
const video = event.currentTarget;
|
||||||
setRemaining(Math.max(0, video.duration - video.currentTime));
|
// Preview priming also seeks — only track the countdown while playing.
|
||||||
|
if (!video.paused && Number.isFinite(video.duration)) {
|
||||||
|
setRemaining(Math.max(0, video.duration - video.currentTime));
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onEnded={() => {
|
onEnded={() => {
|
||||||
setPlaying(false);
|
setPlaying(false);
|
||||||
|
|
|
||||||
57
packages/web/src/features/messaging/ui/VideoThumb.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import type { ReactElement, SyntheticEvent } from 'react';
|
||||||
|
|
||||||
|
// Fallback preview offset when the duration is unknown.
|
||||||
|
export const PREVIEW_FRAME_SEC = 0.1;
|
||||||
|
|
||||||
|
// Pick a frame that's actually lit: cameras need warm-up, so frame ~0 is often
|
||||||
|
// black. Mid-clip for short notes, capped at 1s in for longer videos.
|
||||||
|
const previewTime = (duration: number): number =>
|
||||||
|
Number.isFinite(duration) && duration > 0 ? Math.min(1, duration / 2) : PREVIEW_FRAME_SEC;
|
||||||
|
|
||||||
|
// Make a paused <video> show a real frame instead of black. Two problems are
|
||||||
|
// handled at once:
|
||||||
|
// - Browsers don't decode any frame until asked to seek.
|
||||||
|
// - MediaRecorder WebM (our video notes) reports duration=Infinity and isn't
|
||||||
|
// seekable until the file is scanned; seeking far past the end forces that
|
||||||
|
// scan, clamps to the real end (fixing `duration`), and only then can we
|
||||||
|
// land on a real preview frame.
|
||||||
|
export const primePreview = (video: HTMLVideoElement): void => {
|
||||||
|
if (video.dataset['primed'] === '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
video.dataset['primed'] = '1';
|
||||||
|
|
||||||
|
if (Number.isFinite(video.duration) && video.duration > 0) {
|
||||||
|
video.currentTime = previewTime(video.duration);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSeeked = (): void => {
|
||||||
|
video.removeEventListener('seeked', onSeeked);
|
||||||
|
video.currentTime = previewTime(video.duration);
|
||||||
|
};
|
||||||
|
video.addEventListener('seeked', onSeeked);
|
||||||
|
// Clamped to the real end once the scan completes.
|
||||||
|
video.currentTime = 1e9;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const onPrimeMetadata = (event: SyntheticEvent<HTMLVideoElement>): void => {
|
||||||
|
primePreview(event.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
url: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static video preview: one decoded, lit frame instead of a black rectangle.
|
||||||
|
export const VideoThumb = ({ url, className }: Props): ReactElement => (
|
||||||
|
<video
|
||||||
|
src={url}
|
||||||
|
className={className}
|
||||||
|
preload="metadata"
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
onLoadedMetadata={onPrimeMetadata}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import { PlayIcon, PauseIcon } from '../../../shared/ui';
|
import { PlayIcon, PauseIcon } from '../../../shared/ui';
|
||||||
|
import { claimPlayback } from '../playback';
|
||||||
|
|
||||||
const BARS = 44;
|
const BARS = 44;
|
||||||
|
|
||||||
|
|
@ -119,7 +120,8 @@ export const VoiceMessage = ({ url, seed, durationSec, mine }: Props): ReactElem
|
||||||
ref={audioRef}
|
ref={audioRef}
|
||||||
src={url}
|
src={url}
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
onPlay={() => {
|
onPlay={(event) => {
|
||||||
|
claimPlayback(event.currentTarget);
|
||||||
setPlaying(true);
|
setPlaying(true);
|
||||||
}}
|
}}
|
||||||
onPause={() => {
|
onPause={() => {
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export { ProfilePanel } from './ui/ProfilePanel';
|
export { ProfilePanel } from './ui/ProfilePanel';
|
||||||
|
export { GroupInfoPanel } from './ui/GroupInfoPanel';
|
||||||
|
|
|
||||||
175
packages/web/src/features/profile/ui/GroupInfoPanel.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import type { Conversation, ConversationMember, PublicUser } from '@altricade/core';
|
||||||
|
import {
|
||||||
|
listMembers,
|
||||||
|
getAvatarUploadUrl,
|
||||||
|
uploadToUrl,
|
||||||
|
setConversationAvatar,
|
||||||
|
} from '@altricade/core/api';
|
||||||
|
import { apiConfig } from '../../../shared/api';
|
||||||
|
import { CloseIcon, ChevronLeftIcon, UsersIcon, MegaphoneIcon } from '../../../shared/ui';
|
||||||
|
import { MediaViewerProvider } from '../../messaging';
|
||||||
|
import { SharedMedia } from './SharedMedia';
|
||||||
|
import { HeroAvatar } from './HeroAvatar';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
conversation: Conversation;
|
||||||
|
/** Current user id — the creator may change the group/channel photo. */
|
||||||
|
meId: string;
|
||||||
|
onlineMap: Record<string, boolean>;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Open a member's user profile. */
|
||||||
|
onOpenUser: (user: PublicUser) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleLabel = (role: string): string | null => {
|
||||||
|
if (role === 'owner') return 'owner';
|
||||||
|
if (role === 'admin') return 'admin';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sort owner → admins → members, then by name.
|
||||||
|
const roleRank = (role: string): number => (role === 'owner' ? 0 : role === 'admin' ? 1 : 2);
|
||||||
|
|
||||||
|
// Telegram-style info page for a group or channel: hero, description, shared
|
||||||
|
// media, and the member/subscriber list.
|
||||||
|
export const GroupInfoPanel = ({
|
||||||
|
conversation,
|
||||||
|
meId,
|
||||||
|
onlineMap,
|
||||||
|
onClose,
|
||||||
|
onOpenUser,
|
||||||
|
}: Props): ReactElement => {
|
||||||
|
const [members, setMembers] = useState<ConversationMember[]>([]);
|
||||||
|
// Local copy so a photo change reflects immediately (the list also updates
|
||||||
|
// via the broadcast, but this panel holds its own conversation object).
|
||||||
|
const [conv, setConv] = useState<Conversation>(conversation);
|
||||||
|
const isChannel = conv.type === 'channel';
|
||||||
|
const canEditPhoto = conv.createdBy === meId;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setConv(conversation);
|
||||||
|
}, [conversation]);
|
||||||
|
|
||||||
|
const onPickAvatar = (file: File): void => {
|
||||||
|
const mime = file.type.length > 0 ? file.type : 'application/octet-stream';
|
||||||
|
void (async () => {
|
||||||
|
const target = await getAvatarUploadUrl(apiConfig, { mime, size: file.size });
|
||||||
|
await uploadToUrl(target.uploadUrl, file, mime);
|
||||||
|
setConv(await setConversationAvatar(apiConfig, conv.id, { objectKey: target.objectKey }));
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setMembers([]);
|
||||||
|
void listMembers(apiConfig, conv.id)
|
||||||
|
.then((list) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setMembers(
|
||||||
|
[...list].sort((a, b) => {
|
||||||
|
const byRole = roleRank(a.role) - roleRank(b.role);
|
||||||
|
return byRole !== 0 ? byRole : a.user.displayName.localeCompare(b.user.displayName);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* not a member / not permitted */
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [conv.id]);
|
||||||
|
|
||||||
|
const title = conv.title ?? (isChannel ? 'Channel' : 'Group');
|
||||||
|
const countLabel = isChannel
|
||||||
|
? `${String(members.length)} subscriber${members.length === 1 ? '' : 's'}`
|
||||||
|
: `${String(members.length)} member${members.length === 1 ? '' : 's'}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MediaViewerProvider>
|
||||||
|
<aside className="profile-panel" aria-label={`${title} info`}>
|
||||||
|
<div className="profile-header">
|
||||||
|
<button type="button" className="icon-btn profile-back" aria-label="Back" onClick={onClose}>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
</button>
|
||||||
|
<span className="profile-header-title">{isChannel ? 'Channel info' : 'Group info'}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn profile-close"
|
||||||
|
aria-label="Close"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="profile-scroll">
|
||||||
|
<div className="profile-hero">
|
||||||
|
<HeroAvatar
|
||||||
|
url={conv.avatarUrl}
|
||||||
|
fallback={isChannel ? <MegaphoneIcon size={40} /> : <UsersIcon size={40} />}
|
||||||
|
alt={title}
|
||||||
|
{...(canEditPhoto ? { onPick: onPickAvatar } : {})}
|
||||||
|
/>
|
||||||
|
<h2 className="profile-name">{title}</h2>
|
||||||
|
<p className="profile-presence">{countLabel}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{conv.description !== null && conv.description !== '' ? (
|
||||||
|
<div className="profile-info">
|
||||||
|
<div className="profile-info-row">
|
||||||
|
<span className="profile-info-value profile-about">{conv.description}</span>
|
||||||
|
<span className="profile-info-label">{isChannel ? 'About' : 'Description'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SharedMedia conversationId={conv.id} />
|
||||||
|
|
||||||
|
<div className="profile-shared">
|
||||||
|
<p className="rail-section-title">{isChannel ? 'Subscribers' : 'Members'}</p>
|
||||||
|
<div className="profile-list">
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<p className="rail-empty">No members</p>
|
||||||
|
) : (
|
||||||
|
members.map((member) => {
|
||||||
|
const online = onlineMap[member.userId] === true;
|
||||||
|
const badge = roleLabel(member.role);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={member.userId}
|
||||||
|
type="button"
|
||||||
|
className="member-row"
|
||||||
|
onClick={() => {
|
||||||
|
onOpenUser(member.user);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="conv-avatar-wrap">
|
||||||
|
{member.user.avatarUrl !== null ? (
|
||||||
|
<img src={member.user.avatarUrl} alt="" className="avatar member-avatar" />
|
||||||
|
) : (
|
||||||
|
<span className="avatar member-avatar avatar-placeholder">
|
||||||
|
{member.user.displayName.charAt(0)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{online ? <span className="online-dot" aria-label="online" /> : null}
|
||||||
|
</span>
|
||||||
|
<span className="member-main">
|
||||||
|
<span className="member-name">{member.user.displayName}</span>
|
||||||
|
<span className="member-sub">@{member.user.username}</span>
|
||||||
|
</span>
|
||||||
|
{badge !== null ? <span className="member-role">{badge}</span> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</MediaViewerProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
53
packages/web/src/features/profile/ui/HeroAvatar.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import type { ReactElement, ReactNode, SyntheticEvent } from 'react';
|
||||||
|
import { CameraIcon } from '../../../shared/ui';
|
||||||
|
import { useMediaViewer } from '../../messaging';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
url: string | null;
|
||||||
|
/** Placeholder content when there's no photo (initial letter or an icon). */
|
||||||
|
fallback: ReactNode;
|
||||||
|
alt: string;
|
||||||
|
/** When provided, shows a camera overlay that lets the user pick a new photo. */
|
||||||
|
onPick?: (file: File) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile/group hero avatar: click the photo to view it full-size in the
|
||||||
|
// lightbox; an optional camera overlay lets owners change it. Must be rendered
|
||||||
|
// inside a MediaViewerProvider.
|
||||||
|
export const HeroAvatar = ({ url, fallback, alt, onPick }: Props): ReactElement => {
|
||||||
|
const viewer = useMediaViewer();
|
||||||
|
|
||||||
|
const onFile = (event: SyntheticEvent<HTMLInputElement>): void => {
|
||||||
|
const input = event.currentTarget;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
input.value = '';
|
||||||
|
if (file !== undefined) {
|
||||||
|
onPick?.(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hero-avatar-wrap">
|
||||||
|
{url !== null ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hero-avatar-view"
|
||||||
|
aria-label="View photo"
|
||||||
|
onClick={() => {
|
||||||
|
viewer.open({ type: 'image', url, name: alt });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img src={url} alt="" className="profile-avatar" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="profile-avatar avatar-placeholder">{fallback}</span>
|
||||||
|
)}
|
||||||
|
{onPick !== undefined ? (
|
||||||
|
<label className="hero-avatar-edit" title="Change photo">
|
||||||
|
<CameraIcon size={18} />
|
||||||
|
<input type="file" accept="image/*" hidden onChange={onFile} />
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,19 +1,12 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import type { Conversation, Message, Presence, PublicUser, MediaTab } from '@altricade/core';
|
import type { Conversation, Presence, PublicUser } from '@altricade/core';
|
||||||
import { getPresence, listConversationMedia, getMediaUrl } from '@altricade/core/api';
|
import { getPresence } from '@altricade/core/api';
|
||||||
import { apiConfig } from '../../../shared/api';
|
import { apiConfig } from '../../../shared/api';
|
||||||
import {
|
import { CloseIcon, ChevronLeftIcon, SendIcon } from '../../../shared/ui';
|
||||||
CloseIcon,
|
import { MediaViewerProvider } from '../../messaging';
|
||||||
ChevronLeftIcon,
|
import { SharedMedia } from './SharedMedia';
|
||||||
FileIcon,
|
import { HeroAvatar } from './HeroAvatar';
|
||||||
DownloadIcon,
|
|
||||||
PlayIcon,
|
|
||||||
SendIcon,
|
|
||||||
} from '../../../shared/ui';
|
|
||||||
import { VoiceMessage, MediaViewerProvider, useMediaViewer } from '../../messaging';
|
|
||||||
|
|
||||||
const PAGE_SIZE = 30;
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: PublicUser;
|
user: PublicUser;
|
||||||
|
|
@ -25,12 +18,6 @@ interface Props {
|
||||||
onMessage: (username: string) => void;
|
onMessage: (username: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatSize = (bytes: number): string => {
|
|
||||||
if (bytes < 1024) return `${String(bytes)} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const lastSeenLabel = (presence: Presence | null, online: boolean): string => {
|
const lastSeenLabel = (presence: Presence | null, online: boolean): string => {
|
||||||
if (online) {
|
if (online) {
|
||||||
return 'online';
|
return 'online';
|
||||||
|
|
@ -53,217 +40,6 @@ const lastSeenLabel = (presence: Presence | null, online: boolean): string => {
|
||||||
return `last seen ${then.toLocaleDateString([], { day: 'numeric', month: 'short' })}`;
|
return `last seen ${then.toLocaleDateString([], { day: 'numeric', month: 'short' })}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resolves the presigned URL once, then renders per kind.
|
|
||||||
const useMediaUrl = (conversationId: string, messageId: string): string | null => {
|
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
getMediaUrl(apiConfig, conversationId, messageId)
|
|
||||||
.then((resolved) => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setUrl(resolved);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
/* media may be unavailable */
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [conversationId, messageId]);
|
|
||||||
return url;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MediaCell = ({
|
|
||||||
conversationId,
|
|
||||||
message,
|
|
||||||
}: {
|
|
||||||
conversationId: string;
|
|
||||||
message: Message;
|
|
||||||
}): ReactElement => {
|
|
||||||
const url = useMediaUrl(conversationId, message.id);
|
|
||||||
const viewer = useMediaViewer();
|
|
||||||
const media = message.media;
|
|
||||||
if (media === null) {
|
|
||||||
return <span className="media-cell media-cell-empty" />;
|
|
||||||
}
|
|
||||||
if (url === null) {
|
|
||||||
return <span className="media-cell media-cell-empty" aria-hidden="true" />;
|
|
||||||
}
|
|
||||||
const isImage = media.kind === 'image';
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="media-cell"
|
|
||||||
aria-label={media.name}
|
|
||||||
onClick={() => {
|
|
||||||
viewer.open({ type: isImage ? 'image' : 'video', url, name: media.name });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isImage ? (
|
|
||||||
<img src={url} alt="" loading="lazy" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<video src={url} preload="metadata" muted />
|
|
||||||
<span className="media-cell-play">
|
|
||||||
<PlayIcon size={18} />
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const FileRow = ({
|
|
||||||
conversationId,
|
|
||||||
message,
|
|
||||||
}: {
|
|
||||||
conversationId: string;
|
|
||||||
message: Message;
|
|
||||||
}): ReactElement | null => {
|
|
||||||
const url = useMediaUrl(conversationId, message.id);
|
|
||||||
const media = message.media;
|
|
||||||
if (media === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
className="profile-file"
|
|
||||||
href={url ?? undefined}
|
|
||||||
download={media.name}
|
|
||||||
aria-disabled={url === null}
|
|
||||||
>
|
|
||||||
<span className="media-file-icon">
|
|
||||||
<FileIcon size={20} />
|
|
||||||
</span>
|
|
||||||
<span className="media-file-meta">
|
|
||||||
<span className="media-file-name">{media.name}</span>
|
|
||||||
<span className="media-file-size">{formatSize(media.size)}</span>
|
|
||||||
</span>
|
|
||||||
<DownloadIcon size={18} className="media-file-dl" />
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const VoiceRow = ({
|
|
||||||
conversationId,
|
|
||||||
message,
|
|
||||||
}: {
|
|
||||||
conversationId: string;
|
|
||||||
message: Message;
|
|
||||||
}): ReactElement | null => {
|
|
||||||
const url = useMediaUrl(conversationId, message.id);
|
|
||||||
const media = message.media;
|
|
||||||
if (media === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (url === null) {
|
|
||||||
return <span className="media-loading">Loading…</span>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="profile-voice">
|
|
||||||
<VoiceMessage url={url} seed={message.id} durationSec={media.durationSec} mine={false} />
|
|
||||||
<span className="profile-voice-date muted">
|
|
||||||
{new Date(message.createdAt).toLocaleDateString([], { day: 'numeric', month: 'short' })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TABS: readonly { key: MediaTab; label: string }[] = [
|
|
||||||
{ key: 'media', label: 'Media' },
|
|
||||||
{ key: 'files', label: 'Files' },
|
|
||||||
{ key: 'voice', label: 'Voice' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SharedMedia = ({ conversation }: { conversation: Conversation }): ReactElement => {
|
|
||||||
const [tab, setTab] = useState<MediaTab>('media');
|
|
||||||
const [items, setItems] = useState<Message[]>([]);
|
|
||||||
const [exhausted, setExhausted] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
setItems([]);
|
|
||||||
setExhausted(false);
|
|
||||||
setLoading(true);
|
|
||||||
void listConversationMedia(apiConfig, conversation.id, tab)
|
|
||||||
.then((batch) => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setItems(batch);
|
|
||||||
setExhausted(batch.length < PAGE_SIZE);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [conversation.id, tab]);
|
|
||||||
|
|
||||||
const loadMore = async (): Promise<void> => {
|
|
||||||
const last = items[items.length - 1];
|
|
||||||
if (last === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const batch = await listConversationMedia(apiConfig, conversation.id, tab, last.seq);
|
|
||||||
setItems((prev) => [...prev, ...batch]);
|
|
||||||
setExhausted(batch.length < PAGE_SIZE);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="profile-shared">
|
|
||||||
<div className="profile-tabs" role="tablist">
|
|
||||||
{TABS.map((entry) => (
|
|
||||||
<button
|
|
||||||
key={entry.key}
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
className="profile-tab"
|
|
||||||
aria-selected={tab === entry.key}
|
|
||||||
onClick={() => {
|
|
||||||
setTab(entry.key);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{entry.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<p className="rail-empty">Loading…</p>
|
|
||||||
) : items.length === 0 ? (
|
|
||||||
<p className="rail-empty">Nothing shared yet</p>
|
|
||||||
) : tab === 'media' ? (
|
|
||||||
<div className="media-grid">
|
|
||||||
{items.map((message) => (
|
|
||||||
<MediaCell key={message.id} conversationId={conversation.id} message={message} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="profile-list">
|
|
||||||
{items.map((message) =>
|
|
||||||
tab === 'files' ? (
|
|
||||||
<FileRow key={message.id} conversationId={conversation.id} message={message} />
|
|
||||||
) : (
|
|
||||||
<VoiceRow key={message.id} conversationId={conversation.id} message={message} />
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !exhausted && items.length > 0 ? (
|
|
||||||
<button type="button" className="profile-more" onClick={() => void loadMore()}>
|
|
||||||
Load more
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ProfilePanel = ({
|
export const ProfilePanel = ({
|
||||||
user,
|
user,
|
||||||
conversation,
|
conversation,
|
||||||
|
|
@ -311,13 +87,7 @@ export const ProfilePanel = ({
|
||||||
|
|
||||||
<div className="profile-scroll">
|
<div className="profile-scroll">
|
||||||
<div className="profile-hero">
|
<div className="profile-hero">
|
||||||
{user.avatarUrl !== null ? (
|
<HeroAvatar url={user.avatarUrl} fallback={user.displayName.charAt(0)} alt={user.displayName} />
|
||||||
<img src={user.avatarUrl} alt="" className="profile-avatar" />
|
|
||||||
) : (
|
|
||||||
<span className="profile-avatar avatar-placeholder">
|
|
||||||
{user.displayName.charAt(0)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<h2 className="profile-name">{user.displayName}</h2>
|
<h2 className="profile-name">{user.displayName}</h2>
|
||||||
<p className={online ? 'profile-presence is-online' : 'profile-presence'}>
|
<p className={online ? 'profile-presence is-online' : 'profile-presence'}>
|
||||||
{lastSeenLabel(presence, online)}
|
{lastSeenLabel(presence, online)}
|
||||||
|
|
@ -341,7 +111,7 @@ export const ProfilePanel = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{conversation !== null ? <SharedMedia conversation={conversation} /> : null}
|
{conversation !== null ? <SharedMedia conversationId={conversation.id} /> : null}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</MediaViewerProvider>
|
</MediaViewerProvider>
|
||||||
|
|
|
||||||
309
packages/web/src/features/profile/ui/SharedMedia.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import type { Message, MediaTab } from '@altricade/core';
|
||||||
|
import { listConversationMedia, getMediaUrl } from '@altricade/core/api';
|
||||||
|
import { apiConfig } from '../../../shared/api';
|
||||||
|
import { FileIcon, DownloadIcon, PlayIcon, PauseIcon } from '../../../shared/ui';
|
||||||
|
import { VideoThumb, useMediaViewer } from '../../messaging';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 30;
|
||||||
|
|
||||||
|
const formatSize = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) return `${String(bytes)} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve a presigned media URL once for a message.
|
||||||
|
const useMediaUrl = (conversationId: string, messageId: string): string | null => {
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
getMediaUrl(apiConfig, conversationId, messageId)
|
||||||
|
.then((resolved) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setUrl(resolved);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* media may be unavailable */
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [conversationId, messageId]);
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MediaCell = ({
|
||||||
|
conversationId,
|
||||||
|
message,
|
||||||
|
}: {
|
||||||
|
conversationId: string;
|
||||||
|
message: Message;
|
||||||
|
}): ReactElement => {
|
||||||
|
const url = useMediaUrl(conversationId, message.id);
|
||||||
|
const viewer = useMediaViewer();
|
||||||
|
const media = message.media;
|
||||||
|
if (media === null || url === null) {
|
||||||
|
return <span className="media-cell media-cell-empty" aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
const isImage = media.kind === 'image';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="media-cell"
|
||||||
|
aria-label={media.name}
|
||||||
|
onClick={() => {
|
||||||
|
viewer.open({ type: isImage ? 'image' : 'video', url, name: media.name });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isImage ? (
|
||||||
|
<img src={url} alt="" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<VideoThumb url={url} />
|
||||||
|
<span className="media-cell-play">
|
||||||
|
<PlayIcon size={18} />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FileRow = ({
|
||||||
|
conversationId,
|
||||||
|
message,
|
||||||
|
}: {
|
||||||
|
conversationId: string;
|
||||||
|
message: Message;
|
||||||
|
}): ReactElement | null => {
|
||||||
|
const url = useMediaUrl(conversationId, message.id);
|
||||||
|
const media = message.media;
|
||||||
|
if (media === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className="profile-file"
|
||||||
|
href={url ?? undefined}
|
||||||
|
download={media.name}
|
||||||
|
aria-disabled={url === null}
|
||||||
|
>
|
||||||
|
<span className="media-file-icon">
|
||||||
|
<FileIcon size={20} />
|
||||||
|
</span>
|
||||||
|
<span className="media-file-meta">
|
||||||
|
<span className="media-file-name">{media.name}</span>
|
||||||
|
<span className="media-file-size">{formatSize(media.size)}</span>
|
||||||
|
</span>
|
||||||
|
<DownloadIcon size={18} className="media-file-dl" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatRowDate = (iso: string): string =>
|
||||||
|
new Date(iso).toLocaleDateString([], { day: 'numeric', month: 'short', year: 'numeric' });
|
||||||
|
|
||||||
|
const monthLabel = (iso: string): string =>
|
||||||
|
new Date(iso).toLocaleDateString([], { month: 'long', year: 'numeric' }).toUpperCase();
|
||||||
|
|
||||||
|
// The Voice tab mixes voice notes and round video messages, rendered as
|
||||||
|
// compact Telegram-style rows: round thumb / play button, date, sender.
|
||||||
|
const VoiceRow = ({
|
||||||
|
conversationId,
|
||||||
|
message,
|
||||||
|
}: {
|
||||||
|
conversationId: string;
|
||||||
|
message: Message;
|
||||||
|
}): ReactElement | null => {
|
||||||
|
const url = useMediaUrl(conversationId, message.id);
|
||||||
|
const viewer = useMediaViewer();
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const media = message.media;
|
||||||
|
if (media === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = (
|
||||||
|
<span className="note-meta">
|
||||||
|
<span className="note-date">{formatRowDate(message.createdAt)}</span>
|
||||||
|
<span className="note-sender muted">{message.sender.displayName}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (media.kind === 'video_note') {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="note-row note-row-click"
|
||||||
|
disabled={url === null}
|
||||||
|
onClick={() => {
|
||||||
|
if (url !== null) {
|
||||||
|
viewer.open({ type: 'video', url, name: media.name });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="note-thumb">
|
||||||
|
{url !== null ? <VideoThumb url={url} /> : null}
|
||||||
|
<span className="note-thumb-play">
|
||||||
|
<PlayIcon size={16} />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{meta}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = (): void => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (audio === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (audio.paused) {
|
||||||
|
void audio.play();
|
||||||
|
} else {
|
||||||
|
audio.pause();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="note-row">
|
||||||
|
{url !== null ? (
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={url}
|
||||||
|
preload="none"
|
||||||
|
onPlay={() => {
|
||||||
|
setPlaying(true);
|
||||||
|
}}
|
||||||
|
onPause={() => {
|
||||||
|
setPlaying(false);
|
||||||
|
}}
|
||||||
|
onEnded={() => {
|
||||||
|
setPlaying(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="note-play"
|
||||||
|
aria-label={playing ? 'Pause' : 'Play'}
|
||||||
|
disabled={url === null}
|
||||||
|
onClick={toggle}
|
||||||
|
>
|
||||||
|
{playing ? <PauseIcon size={18} /> : <PlayIcon size={18} />}
|
||||||
|
</button>
|
||||||
|
{meta}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TABS: readonly { key: MediaTab; label: string }[] = [
|
||||||
|
{ key: 'media', label: 'Media' },
|
||||||
|
{ key: 'files', label: 'Files' },
|
||||||
|
{ key: 'voice', label: 'Voice' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Shared-media tabs (Media / Files / Voice) for any conversation. Used by the
|
||||||
|
// user profile panel and the group/channel info panel. Must be rendered inside
|
||||||
|
// a MediaViewerProvider (the cells open the lightbox).
|
||||||
|
export const SharedMedia = ({ conversationId }: { conversationId: string }): ReactElement => {
|
||||||
|
const [tab, setTab] = useState<MediaTab>('media');
|
||||||
|
const [items, setItems] = useState<Message[]>([]);
|
||||||
|
const [exhausted, setExhausted] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setItems([]);
|
||||||
|
setExhausted(false);
|
||||||
|
setLoading(true);
|
||||||
|
void listConversationMedia(apiConfig, conversationId, tab)
|
||||||
|
.then((batch) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setItems(batch);
|
||||||
|
setExhausted(batch.length < PAGE_SIZE);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [conversationId, tab]);
|
||||||
|
|
||||||
|
const loadMore = async (): Promise<void> => {
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
if (last === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const batch = await listConversationMedia(apiConfig, conversationId, tab, last.seq);
|
||||||
|
setItems((prev) => [...prev, ...batch]);
|
||||||
|
setExhausted(batch.length < PAGE_SIZE);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="profile-shared">
|
||||||
|
<div className="profile-tabs" role="tablist">
|
||||||
|
{TABS.map((entry) => (
|
||||||
|
<button
|
||||||
|
key={entry.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
className="profile-tab"
|
||||||
|
aria-selected={tab === entry.key}
|
||||||
|
onClick={() => {
|
||||||
|
setTab(entry.key);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="rail-empty">Loading…</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="rail-empty">Nothing shared yet</p>
|
||||||
|
) : tab === 'media' ? (
|
||||||
|
<div className="media-grid">
|
||||||
|
{items.map((message) => (
|
||||||
|
<MediaCell key={message.id} conversationId={conversationId} message={message} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="profile-list">
|
||||||
|
{items.map((message, index) => {
|
||||||
|
const previous = index > 0 ? items[index - 1] : undefined;
|
||||||
|
const showMonth =
|
||||||
|
tab === 'voice' &&
|
||||||
|
previous !== undefined &&
|
||||||
|
monthLabel(previous.createdAt) !== monthLabel(message.createdAt);
|
||||||
|
return (
|
||||||
|
<Fragment key={message.id}>
|
||||||
|
{showMonth ? <p className="note-month muted">{monthLabel(message.createdAt)}</p> : null}
|
||||||
|
{tab === 'files' ? (
|
||||||
|
<FileRow conversationId={conversationId} message={message} />
|
||||||
|
) : (
|
||||||
|
<VoiceRow conversationId={conversationId} message={message} />
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !exhausted && items.length > 0 ? (
|
||||||
|
<button type="button" className="profile-more" onClick={() => void loadMore()}>
|
||||||
|
Load more
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,18 +1,12 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { ReactElement, SyntheticEvent } from 'react';
|
import type { ReactElement, SyntheticEvent } from 'react';
|
||||||
|
import type { UpdateMeBody } from '@altricade/core';
|
||||||
import { updateMe, getAvatarUploadUrl, uploadToUrl, setAvatar, ApiError } from '@altricade/core/api';
|
import { updateMe, getAvatarUploadUrl, uploadToUrl, setAvatar, ApiError } from '@altricade/core/api';
|
||||||
import { useSession } from '../../../entities/session';
|
import { useSession } from '../../../entities/session';
|
||||||
import { apiConfig } from '../../../shared/api';
|
import { apiConfig } from '../../../shared/api';
|
||||||
import { useTheme } from '../../../shared/theme';
|
import { useTheme } from '../../../shared/theme';
|
||||||
import type { ThemePreference } from '../../../shared/theme';
|
import type { ThemePreference } from '../../../shared/theme';
|
||||||
import {
|
import { SunIcon, MoonIcon, MonitorIcon, LogOutIcon, CheckIcon, CloseIcon, CameraIcon } from '../../../shared/ui';
|
||||||
SunIcon,
|
|
||||||
MoonIcon,
|
|
||||||
MonitorIcon,
|
|
||||||
LogOutIcon,
|
|
||||||
EditIcon,
|
|
||||||
CheckIcon,
|
|
||||||
} from '../../../shared/ui';
|
|
||||||
|
|
||||||
const THEME_OPTIONS: readonly { value: ThemePreference; label: string; icon: ReactElement }[] = [
|
const THEME_OPTIONS: readonly { value: ThemePreference; label: string; icon: ReactElement }[] = [
|
||||||
{ value: 'light', label: 'Light', icon: <SunIcon size={17} /> },
|
{ value: 'light', label: 'Light', icon: <SunIcon size={17} /> },
|
||||||
|
|
@ -24,13 +18,116 @@ interface Props {
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Telegram-style settings page: account (avatar, name), appearance, log out.
|
// A grouped, tap-to-edit info row (Telegram "Edit profile" style). Shows the
|
||||||
|
// value; clicking swaps in an input with save/cancel and surfaces field errors.
|
||||||
|
const EditableRow = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
placeholder,
|
||||||
|
prefix,
|
||||||
|
required = false,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
placeholder: string;
|
||||||
|
prefix?: string;
|
||||||
|
required?: boolean;
|
||||||
|
onSave: (next: string) => Promise<void>;
|
||||||
|
}): ReactElement => {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const start = (): void => {
|
||||||
|
setDraft(value);
|
||||||
|
setError(null);
|
||||||
|
setEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = (): void => {
|
||||||
|
setEditing(false);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async (event: SyntheticEvent): Promise<void> => {
|
||||||
|
event.preventDefault();
|
||||||
|
const trimmed = draft.trim();
|
||||||
|
if (required && trimmed === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (trimmed === value) {
|
||||||
|
cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await onSave(trimmed);
|
||||||
|
setEditing(false);
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof ApiError ? caught.message : 'Could not save');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<form className="set-row set-row-edit" onSubmit={(event) => void submit(event)}>
|
||||||
|
<div className="set-input-wrap">
|
||||||
|
{prefix !== undefined ? <span className="set-prefix">{prefix}</span> : null}
|
||||||
|
<input
|
||||||
|
className="set-input"
|
||||||
|
autoFocus
|
||||||
|
value={draft}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(event) => {
|
||||||
|
setDraft(event.target.value);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button type="button" className="icon-btn set-row-btn" aria-label="Cancel" onClick={cancel}>
|
||||||
|
<CloseIcon size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="icon-btn accent set-row-btn"
|
||||||
|
aria-label="Save"
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<CheckIcon size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error !== null ? <span className="set-row-error">{error}</span> : null}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" className="set-row" onClick={start}>
|
||||||
|
<span className="set-value">
|
||||||
|
{value === '' ? (
|
||||||
|
<span className="set-placeholder">{placeholder}</span>
|
||||||
|
) : (
|
||||||
|
`${prefix ?? ''}${value}`
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="set-label">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Telegram-style settings: avatar hero, grouped editable account info,
|
||||||
|
// appearance, and log out.
|
||||||
export const SettingsPanel = ({ onLogout }: Props): ReactElement | null => {
|
export const SettingsPanel = ({ onLogout }: Props): ReactElement | null => {
|
||||||
const { user, updateUser } = useSession();
|
const { user, updateUser } = useSession();
|
||||||
const { preference, setPreference } = useTheme();
|
const { preference, setPreference } = useTheme();
|
||||||
const [editingName, setEditingName] = useState(false);
|
|
||||||
const [nameDraft, setNameDraft] = useState('');
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
if (user === null) {
|
if (user === null) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -51,110 +148,94 @@ export const SettingsPanel = ({ onLogout }: Props): ReactElement | null => {
|
||||||
})();
|
})();
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveName = async (): Promise<void> => {
|
const save = async (patch: UpdateMeBody): Promise<void> => {
|
||||||
const trimmed = nameDraft.trim();
|
updateUser(await updateMe(apiConfig, patch));
|
||||||
setEditingName(false);
|
|
||||||
if (trimmed === '' || trimmed === user.displayName) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
updateUser(await updateMe(apiConfig, { displayName: trimmed }));
|
|
||||||
} catch (caught) {
|
|
||||||
setError(caught instanceof ApiError ? caught.message : 'Could not update name');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings">
|
<div className="settings">
|
||||||
<div className="settings-hero">
|
<div className="settings-hero">
|
||||||
<label className="settings-avatar-wrap" title="Change avatar">
|
<label className="settings-avatar-wrap" title="Change photo">
|
||||||
{user.avatarUrl !== null ? (
|
{user.avatarUrl !== null ? (
|
||||||
<img src={user.avatarUrl} alt="" className="settings-avatar" />
|
<img src={user.avatarUrl} alt="" className="settings-avatar" />
|
||||||
) : (
|
) : (
|
||||||
<span className="settings-avatar avatar-placeholder">
|
<span className="settings-avatar avatar-placeholder">{user.displayName.charAt(0)}</span>
|
||||||
{user.displayName.charAt(0)}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
<span className="settings-avatar-edit">
|
<span className="settings-avatar-edit">
|
||||||
<EditIcon size={15} />
|
<CameraIcon size={16} />
|
||||||
</span>
|
</span>
|
||||||
<input type="file" accept="image/*" hidden onChange={onAvatar} />
|
<input type="file" accept="image/*" hidden onChange={onAvatar} />
|
||||||
</label>
|
</label>
|
||||||
|
<h2 className="settings-name-display">{user.displayName}</h2>
|
||||||
{editingName ? (
|
|
||||||
<form
|
|
||||||
className="settings-name-form"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
void saveName();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className="field settings-name-input"
|
|
||||||
autoFocus
|
|
||||||
maxLength={64}
|
|
||||||
value={nameDraft}
|
|
||||||
onChange={(event) => {
|
|
||||||
setNameDraft(event.target.value);
|
|
||||||
}}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
setEditingName(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button type="submit" className="icon-btn accent" aria-label="Save name">
|
|
||||||
<CheckIcon size={17} />
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="settings-name"
|
|
||||||
title="Edit display name"
|
|
||||||
onClick={() => {
|
|
||||||
setNameDraft(user.displayName);
|
|
||||||
setEditingName(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user.displayName}
|
|
||||||
<EditIcon size={15} className="settings-name-edit" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<p className="muted settings-handle">@{user.username}</p>
|
<p className="muted settings-handle">@{user.username}</p>
|
||||||
{error !== null ? <p className="form-error">{error}</p> : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="settings-section">
|
<div className="set-group">
|
||||||
|
<p className="rail-section-title">Account</p>
|
||||||
|
<div className="set-card">
|
||||||
|
<EditableRow
|
||||||
|
label="Name"
|
||||||
|
value={user.displayName}
|
||||||
|
placeholder="Your name"
|
||||||
|
required
|
||||||
|
onSave={(next) => save({ displayName: next })}
|
||||||
|
/>
|
||||||
|
<EditableRow
|
||||||
|
label="Username"
|
||||||
|
value={user.username}
|
||||||
|
placeholder="username"
|
||||||
|
prefix="@"
|
||||||
|
required
|
||||||
|
onSave={(next) => save({ username: next })}
|
||||||
|
/>
|
||||||
|
<EditableRow
|
||||||
|
label="Phone"
|
||||||
|
value={user.phone ?? ''}
|
||||||
|
placeholder="Add phone (+1234567890)"
|
||||||
|
onSave={(next) => save({ phone: next === '' ? null : next })}
|
||||||
|
/>
|
||||||
|
<EditableRow
|
||||||
|
label="Email"
|
||||||
|
value={user.email ?? ''}
|
||||||
|
placeholder="Add email"
|
||||||
|
onSave={(next) => save({ email: next === '' ? null : next })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="set-group">
|
||||||
<p className="rail-section-title">Appearance</p>
|
<p className="rail-section-title">Appearance</p>
|
||||||
<div className="settings-row">
|
<div className="set-card">
|
||||||
<span className="settings-row-label">Theme</span>
|
<div className="set-row set-row-static">
|
||||||
<div className="theme-switch" role="group" aria-label="Theme">
|
<span className="theme-switch" role="group" aria-label="Theme">
|
||||||
{THEME_OPTIONS.map((option) => (
|
{THEME_OPTIONS.map((option) => (
|
||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
type="button"
|
type="button"
|
||||||
className="theme-option"
|
className="theme-option"
|
||||||
title={option.label}
|
title={option.label}
|
||||||
aria-label={option.label}
|
aria-label={option.label}
|
||||||
aria-pressed={preference === option.value}
|
aria-pressed={preference === option.value}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPreference(option.value);
|
setPreference(option.value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{option.icon}
|
{option.icon}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="set-label">Theme</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="settings-section">
|
<div className="set-group">
|
||||||
<button type="button" className="settings-logout" onClick={onLogout}>
|
<div className="set-card">
|
||||||
<LogOutIcon size={18} />
|
<button type="button" className="settings-logout" onClick={onLogout}>
|
||||||
Log out
|
<LogOutIcon size={18} />
|
||||||
</button>
|
Log out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
1
packages/web/src/shared/lib/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { useDebouncedValue } from './useDebouncedValue';
|
||||||
19
packages/web/src/shared/lib/useDebouncedValue.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
// Returns `value` delayed by `delayMs`; rapid changes reset the timer so only
|
||||||
|
// the last value after a quiet period propagates. Pair with a useEffect keyed
|
||||||
|
// on the result to debounce server calls (search-as-you-type, etc.).
|
||||||
|
export const useDebouncedValue = <T>(value: T, delayMs = 300): T => {
|
||||||
|
const [debounced, setDebounced] = useState<T>(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebounced(value);
|
||||||
|
}, delayMs);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [value, delayMs]);
|
||||||
|
|
||||||
|
return debounced;
|
||||||
|
};
|
||||||
|
|
@ -13,7 +13,7 @@ export interface ThemeContextValue {
|
||||||
|
|
||||||
export const ThemeContext = createContext<ThemeContextValue | null>(null);
|
export const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||||
|
|
||||||
const STORAGE_KEY = 'altricade.theme';
|
const STORAGE_KEY = 'zovi.theme';
|
||||||
const DARK_QUERY = '(prefers-color-scheme: dark)';
|
const DARK_QUERY = '(prefers-color-scheme: dark)';
|
||||||
|
|
||||||
const readStoredPreference = (): ThemePreference => {
|
const readStoredPreference = (): ThemePreference => {
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,16 @@ export const PauseIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const RefreshIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
|
base(
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
<>
|
||||||
|
<polyline points="23 4 23 10 17 10" />
|
||||||
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
export const DownloadIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
export const DownloadIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
base(
|
base(
|
||||||
size,
|
size,
|
||||||
|
|
@ -326,6 +336,46 @@ export const UserIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
export const ChevronDownIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
export const ChevronDownIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
base(size, className, <polyline points="6 9 12 15 18 9" />);
|
base(size, className, <polyline points="6 9 12 15 18 9" />);
|
||||||
|
|
||||||
|
export const CameraIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
|
base(
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
<>
|
||||||
|
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
|
||||||
|
<circle cx="12" cy="13" r="4" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const MegaphoneIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
|
base(
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
<>
|
||||||
|
<path d="M3 11l17-7v16L3 13v-2z" />
|
||||||
|
<path d="M7.5 13.5V19a1.5 1.5 0 0 0 3 0v-4" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ForwardIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
|
base(
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
<>
|
||||||
|
<polyline points="15 17 20 12 15 7" />
|
||||||
|
<path d="M4 18v-2a4 4 0 0 1 4-4h12" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SelectIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
|
base(
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polyline points="8 12 11 15 16 9" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
export const ReplyIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
export const ReplyIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
base(
|
base(
|
||||||
size,
|
size,
|
||||||
|
|
@ -335,3 +385,18 @@ export const ReplyIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
<path d="M20 18v-2a4 4 0 0 0-4-4H4" />
|
<path d="M20 18v-2a4 4 0 0 0-4-4H4" />
|
||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Zovi brand mark (the "bubble Z") — filled glyph only; the container supplies
|
||||||
|
// the gradient tile so one asset works at every size.
|
||||||
|
export const BrandGlyph = ({ size = 20, className }: IconProps): ReactElement => (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M118 124H394V208L268 304H394V388H196L118 458V304L244 208H118Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export {
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
PauseIcon,
|
PauseIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
|
RefreshIcon,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
DoubleCheckIcon,
|
DoubleCheckIcon,
|
||||||
|
|
@ -24,6 +25,8 @@ export {
|
||||||
SmileIcon,
|
SmileIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
ReplyIcon,
|
ReplyIcon,
|
||||||
|
ForwardIcon,
|
||||||
|
SelectIcon,
|
||||||
PinIcon,
|
PinIcon,
|
||||||
PinOffIcon,
|
PinOffIcon,
|
||||||
FolderIcon,
|
FolderIcon,
|
||||||
|
|
@ -33,6 +36,9 @@ export {
|
||||||
ChatsIcon,
|
ChatsIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
|
MegaphoneIcon,
|
||||||
|
CameraIcon,
|
||||||
|
BrandGlyph,
|
||||||
} from './icons';
|
} from './icons';
|
||||||
export { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from './ContextMenu';
|
export { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from './ContextMenu';
|
||||||
export type { ContextMenuState, UseContextMenu } from './ContextMenu';
|
export type { ContextMenuState, UseContextMenu } from './ContextMenu';
|
||||||
|
|
|
||||||