diff --git a/.env.example b/.env.example index 2fee24f..935cac0 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # ============================================================================ -# Altricade Messenger — environment template. +# Zovi Messenger — environment template. # Copy to `.env` (gitignored) and replace every value below. # 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. VAPID_PUBLIC_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 # at the service-account JSON mounted into the worker; leave empty to disable FCM. FCM_SERVICE_ACCOUNT_FILE= diff --git a/README.md b/README.md index 6c9bf0d..3c93135 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Altricade Messenger +# Zovi Messenger A self-hosted, cross-platform realtime messenger (chat + later calls). TypeScript 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". > Dev notes: +> > - The backend runs in Docker with `node_modules` baked into the image. After > changing backend dependencies, recreate the container so it picks them up: > `docker compose up -d --build --force-recreate backend`. diff --git a/assets/brand/README.md b/assets/brand/README.md new file mode 100644 index 0000000..c93b460 --- /dev/null +++ b/assets/brand/README.md @@ -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 .svg -o out.png resize `. +ICO: `npx png-to-ico > out.ico` (pass a real 256px render). +ICNS: build an `.iconset` (16…1024 incl. @2x) from `icon-macos.svg`, then `iconutil -c icns`. diff --git a/assets/brand/adaptive-icon-foreground.svg b/assets/brand/adaptive-icon-foreground.svg new file mode 100644 index 0000000..8fd625e --- /dev/null +++ b/assets/brand/adaptive-icon-foreground.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/brand/exports/android/adaptive-icon-foreground.png b/assets/brand/exports/android/adaptive-icon-foreground.png new file mode 100644 index 0000000..2b1d083 Binary files /dev/null and b/assets/brand/exports/android/adaptive-icon-foreground.png differ diff --git a/assets/brand/exports/android/icon-1024.png b/assets/brand/exports/android/icon-1024.png new file mode 100644 index 0000000..0794fc1 Binary files /dev/null and b/assets/brand/exports/android/icon-1024.png differ diff --git a/assets/brand/exports/ios/icon-1024.png b/assets/brand/exports/ios/icon-1024.png new file mode 100644 index 0000000..0794fc1 Binary files /dev/null and b/assets/brand/exports/ios/icon-1024.png differ diff --git a/assets/brand/exports/macos/zovi.icns b/assets/brand/exports/macos/zovi.icns new file mode 100644 index 0000000..ddd8e6f Binary files /dev/null and b/assets/brand/exports/macos/zovi.icns differ diff --git a/assets/brand/exports/splash/splash-dark.png b/assets/brand/exports/splash/splash-dark.png new file mode 100644 index 0000000..a334198 Binary files /dev/null and b/assets/brand/exports/splash/splash-dark.png differ diff --git a/assets/brand/exports/splash/splash-light.png b/assets/brand/exports/splash/splash-light.png new file mode 100644 index 0000000..d0bd132 Binary files /dev/null and b/assets/brand/exports/splash/splash-light.png differ diff --git a/assets/brand/exports/windows/zovi.ico b/assets/brand/exports/windows/zovi.ico new file mode 100644 index 0000000..08002f5 Binary files /dev/null and b/assets/brand/exports/windows/zovi.ico differ diff --git a/assets/brand/favicon.svg b/assets/brand/favicon.svg new file mode 100644 index 0000000..18b123d --- /dev/null +++ b/assets/brand/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/brand/icon-macos.svg b/assets/brand/icon-macos.svg new file mode 100644 index 0000000..4abea9a --- /dev/null +++ b/assets/brand/icon-macos.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/assets/brand/icon-rounded.svg b/assets/brand/icon-rounded.svg new file mode 100644 index 0000000..d0690f1 --- /dev/null +++ b/assets/brand/icon-rounded.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/brand/icon.svg b/assets/brand/icon.svg new file mode 100644 index 0000000..c7e8cec --- /dev/null +++ b/assets/brand/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/brand/mark-gradient.svg b/assets/brand/mark-gradient.svg new file mode 100644 index 0000000..9252132 --- /dev/null +++ b/assets/brand/mark-gradient.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/brand/splash-dark.svg b/assets/brand/splash-dark.svg new file mode 100644 index 0000000..5fb651d --- /dev/null +++ b/assets/brand/splash-dark.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/assets/brand/splash-light.svg b/assets/brand/splash-light.svg new file mode 100644 index 0000000..2492884 --- /dev/null +++ b/assets/brand/splash-light.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/infra/nginx/nginx.conf b/infra/nginx/nginx.conf index afc20b3..ccd0f20 100644 --- a/infra/nginx/nginx.conf +++ b/infra/nginx/nginx.conf @@ -55,7 +55,7 @@ http { location / { default_type text/plain; - return 200 "Altricade gateway — /api/* -> backend, /connection/websocket -> centrifugo\n"; + return 200 "Zovi gateway — /api/* -> backend, /connection/websocket -> centrifugo\n"; } } } diff --git a/package.json b/package.json index 7bfc703..4cca201 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "altricade-messenger", + "name": "zovi-messenger", "version": "0.0.0", "private": true, "type": "module", diff --git a/packages/backend/migrations/1720000000008_channels.cjs b/packages/backend/migrations/1720000000008_channels.cjs new file mode 100644 index 0000000..61a0b38 --- /dev/null +++ b/packages/backend/migrations/1720000000008_channels.cjs @@ -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']); +}; diff --git a/packages/backend/migrations/1720000000009_conversation_avatar.cjs b/packages/backend/migrations/1720000000009_conversation_avatar.cjs new file mode 100644 index 0000000..006a2f1 --- /dev/null +++ b/packages/backend/migrations/1720000000009_conversation_avatar.cjs @@ -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']); +}; diff --git a/packages/backend/migrations/1720000000010_reply_forward_pin.cjs b/packages/backend/migrations/1720000000010_reply_forward_pin.cjs new file mode 100644 index 0000000..26f3a8d --- /dev/null +++ b/packages/backend/migrations/1720000000010_reply_forward_pin.cjs @@ -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']); +}; diff --git a/packages/backend/migrations/1720000000011_forward_origin_shape.cjs b/packages/backend/migrations/1720000000011_forward_origin_shape.cjs new file mode 100644 index 0000000..1490ddf --- /dev/null +++ b/packages/backend/migrations/1720000000011_forward_origin_shape.cjs @@ -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; + `); +}; diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 50008b1..b6a157e 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -91,7 +91,7 @@ const buildNotificationsConfig = (): NotificationsConfig => { const privateKey = optional('VAPID_PRIVATE_KEY', ''); const webPush = publicKey !== '' && privateKey !== '' - ? { publicKey, privateKey, subject: optional('VAPID_SUBJECT', 'mailto:admin@altricade.com') } + ? { publicKey, privateKey, subject: optional('VAPID_SUBJECT', 'mailto:admin@zovi.app') } : null; const fcmFile = optional('FCM_SERVICE_ACCOUNT_FILE', ''); return { diff --git a/packages/backend/src/db/schema.ts b/packages/backend/src/db/schema.ts index c1cc134..9852c56 100644 --- a/packages/backend/src/db/schema.ts +++ b/packages/backend/src/db/schema.ts @@ -1,5 +1,5 @@ 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. @@ -33,6 +33,10 @@ export interface ConversationsTable { id: Generated; type: Generated; 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; created_by: string; created_at: Generated; @@ -66,6 +70,17 @@ export interface MessagesTable { media_key: string | null; // jsonb: parsed to a MediaRef on read, JSON string on write. media_meta: ColumnType; + reply_to_id: string | null; + forwarded: Generated; + // jsonb: a ForwardOrigin snapshot ({name, user}), or null (hidden / none). + forwarded_from: ColumnType; +} + +export interface MessagePinsTable { + conversation_id: string; + message_id: string; + pinned_by: string; + pinned_at: Generated; } export interface ContactsTable { @@ -163,4 +178,5 @@ export interface Database { chat_folders: ChatFoldersTable; chat_folder_items: ChatFolderItemsTable; chat_pins: ChatPinsTable; + message_pins: MessagePinsTable; } diff --git a/packages/backend/src/modules/conversations/conversations.mapper.ts b/packages/backend/src/modules/conversations/conversations.mapper.ts index 68cd86b..1e00b7f 100644 --- a/packages/backend/src/modules/conversations/conversations.mapper.ts +++ b/packages/backend/src/modules/conversations/conversations.mapper.ts @@ -14,8 +14,11 @@ export const toConversation = ( unreadCount = 0, ): Conversation => ({ id: row.id, - type: row.type === 'direct' ? 'direct' : 'group', + type: row.type === 'direct' ? 'direct' : row.type === 'channel' ? 'channel' : 'group', 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, createdBy: row.created_by, createdAt: row.created_at.toISOString(), diff --git a/packages/backend/src/modules/conversations/conversations.repository.ts b/packages/backend/src/modules/conversations/conversations.repository.ts index 6ecc863..dc5958c 100644 --- a/packages/backend/src/modules/conversations/conversations.repository.ts +++ b/packages/backend/src/modules/conversations/conversations.repository.ts @@ -27,6 +27,12 @@ const directKeyFor = (a: string, b: string): string => (a < b ? `${a}:${b}` : `$ export interface ConversationsRepository { createGroup(title: string, ownerId: string, memberIds: string[]): Promise; + createChannel( + title: string, + description: string | null, + ownerId: string, + memberIds: string[], + ): Promise; findOrCreateDirect(userA: string, userB: string): Promise; findById(id: string): Promise; listForUser(userId: string): Promise; @@ -39,6 +45,7 @@ export interface ConversationsRepository { listMembers(conversationId: string): Promise; getDeliveryInfo(conversationId: string): Promise; touchLastMessage(conversationId: string): Promise; + setImage(conversationId: string, imageUrl: string): Promise; /** "Clear history" for me: hide everything up to the current max seq; resets unread. Returns that seq. */ clearForUser(conversationId: string, userId: string): Promise; /** "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): Conversatio 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) => { const directKey = directKeyFor(userA, userB); const existing = await db @@ -293,6 +324,14 @@ export const createConversationsRepository = (db: Kysely): Conversatio .execute(); }, + setImage: (conversationId, imageUrl) => + db + .updateTable('conversations') + .set({ image_ref: imageUrl }) + .where('id', '=', conversationId) + .returningAll() + .executeTakeFirst(), + clearForUser, hideForUser: async (conversationId, userId) => { diff --git a/packages/backend/src/modules/conversations/conversations.routes.ts b/packages/backend/src/modules/conversations/conversations.routes.ts index 701e4b1..c4467e2 100644 --- a/packages/backend/src/modules/conversations/conversations.routes.ts +++ b/packages/backend/src/modules/conversations/conversations.routes.ts @@ -2,14 +2,22 @@ import type { FastifyInstance } from 'fastify'; import { createDirectBodySchema, createGroupBodySchema, + createChannelBodySchema, addMemberBodySchema, + setAvatarBodySchema, readBodySchema, conversationSchema, conversationListSchema, conversationMemberListSchema, errorSchema, } 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 idParamsSchema = { @@ -86,6 +94,31 @@ export const conversationsRoutes = (app: FastifyInstance): Promise => { }, ); + 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 } }>( '/conversations/:id', { @@ -169,6 +202,35 @@ export const conversationsRoutes = (app: FastifyInstance): Promise => { }, ); + app.post<{ Params: { id: string }; Body: { objectKey: string } }>( + '/conversations/:id/avatar', + { + schema: { + tags: ['conversations'], + summary: 'Set a group/channel photo (owner/admin, from an uploaded object key)', + security: bearerAuth, + params: idParamsSchema, + body: setAvatarBodySchema, + response: { + 200: conversationSchema, + 400: errorSchema, + 401: errorSchema, + 403: errorSchema, + 404: errorSchema, + }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + const imageUrl = app.mediaService.avatarPublicUrl(request.body.objectKey); + return reply.send( + await app.conversationsService.setAvatar(request.params.id, user.id, imageUrl), + ); + }, + ); + app.post<{ Params: { id: string } }>( '/conversations/:id/clear', { diff --git a/packages/backend/src/modules/conversations/conversations.service.ts b/packages/backend/src/modules/conversations/conversations.service.ts index e66b613..399466c 100644 --- a/packages/backend/src/modules/conversations/conversations.service.ts +++ b/packages/backend/src/modules/conversations/conversations.service.ts @@ -28,6 +28,12 @@ export interface ConversationsServiceDeps { export interface ConversationsService { createDirect(actorId: string, username: string): Promise; createGroup(actorId: string, title: string, memberUsernames: string[]): Promise; + createChannel( + actorId: string, + title: string, + description: string | null, + memberUsernames: string[], + ): Promise; list(userId: string): Promise; get(conversationId: string, userId: string): Promise; listMembers(conversationId: string, userId: string): Promise; @@ -38,6 +44,7 @@ export interface ConversationsService { markRead(conversationId: string, userId: string, seq: number): Promise; clear(conversationId: string, userId: string): Promise; hide(conversationId: string, userId: string): Promise; + setAvatar(conversationId: string, actorId: string, imageUrl: string): Promise; } export const createConversationsService = ( @@ -62,6 +69,18 @@ export const createConversationsService = ( conversation, }); + // Usernames → ids, dropping unknowns and the actor (who is added as owner). + const resolveMemberIds = async (actorId: string, usernames: string[]): Promise => { + 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 = ( action: ConversationMembershipEvent['action'], conversationId: string, @@ -96,13 +115,7 @@ export const createConversationsService = ( }, createGroup: async (actorId, title, memberUsernames) => { - const memberIds: string[] = []; - for (const username of memberUsernames) { - const found = await users.findByUsername(username); - if (found !== undefined && found.id !== actorId) { - memberIds.push(found.id); - } - } + const memberIds = await resolveMemberIds(actorId, memberUsernames); const conversation = toConversation( await conversations.createGroup(title, actorId, memberIds), null, @@ -113,6 +126,18 @@ export const createConversationsService = ( 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) => { const rows = await conversations.listForUser(userId); const directIds = rows.filter((row) => row.type === 'direct').map((row) => row.id); @@ -206,6 +231,32 @@ export const createConversationsService = ( }; 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; + }, }; }; diff --git a/packages/backend/src/modules/messages/messages.mapper.ts b/packages/backend/src/modules/messages/messages.mapper.ts index c5e5c47..b90e4c9 100644 --- a/packages/backend/src/modules/messages/messages.mapper.ts +++ b/packages/backend/src/modules/messages/messages.mapper.ts @@ -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'; +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 = ( row: MessageWithSenderRow, reactions: ReactionSummary[] = [], @@ -24,4 +42,7 @@ export const toMessage = ( deletedAt: row.deleted_at === null ? null : row.deleted_at.toISOString(), reactions, media: row.media_meta, + replyTo: toReplyPreview(row), + forwarded: row.forwarded, + forwardedFrom: row.forwarded_from, }); diff --git a/packages/backend/src/modules/messages/messages.repository.ts b/packages/backend/src/modules/messages/messages.repository.ts index fe09e66..2fcd3f4 100644 --- a/packages/backend/src/modules/messages/messages.repository.ts +++ b/packages/backend/src/modules/messages/messages.repository.ts @@ -1,6 +1,6 @@ import { sql } 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'; export interface MessageWithSenderRow { @@ -20,6 +20,13 @@ export interface MessageWithSenderRow { sender_username: string; sender_display_name: string; 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 { @@ -31,6 +38,9 @@ export interface NewMessage { encryption: string | null; mediaKey: string | null; media: MediaRef | null; + replyToId: string | null; + forwarded: boolean; + forwardedFrom: ForwardOrigin | null; } export interface MessagesRepository { @@ -48,6 +58,20 @@ export interface MessagesRepository { beforeSeq: number | null, limit: number, ): Promise; + /** Ascending page after `afterSeq` (downward pagination from a jumped-to context). */ + listAfter( + conversationId: string, + userId: string, + afterSeq: number, + limit: number, + ): Promise; + /** A window centered on `seq`: `half` rows above and `half` rows from it downward. */ + listContext( + conversationId: string, + userId: string, + seq: number, + half: number, + ): Promise; /** Media messages as seen by `userId` (profile panel tabs), newest first. */ listMedia( conversationId: string, @@ -70,6 +94,9 @@ export interface MessagesRepository { addReaction(messageId: string, userId: string, emoji: string): Promise; removeReaction(messageId: string, userId: string, emoji: string): Promise; reactionsFor(messageIds: string[], userId: string): Promise>; + pin(conversationId: string, messageId: string, pinnedBy: string): Promise; + unpin(conversationId: string, messageId: string): Promise; + listPinned(conversationId: string): Promise; } export const createMessagesRepository = (db: Kysely): MessagesRepository => { @@ -77,7 +104,10 @@ export const createMessagesRepository = (db: Kysely): MessagesReposito db .selectFrom('messages') .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.seq as seq', 'messages.conversation_id as conversation_id', @@ -94,6 +124,13 @@ export const createMessagesRepository = (db: Kysely): MessagesReposito 'users.username as sender_username', 'users.display_name as sender_display_name', '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('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 @@ -131,6 +168,9 @@ export const createMessagesRepository = (db: Kysely): MessagesReposito encryption: input.encryption, media_key: input.mediaKey, 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) => oc.columns(['conversation_id', 'sender_id', 'client_msg_id']).doNothing(), @@ -159,6 +199,30 @@ export const createMessagesRepository = (db: Kysely): MessagesReposito return query.orderBy('messages.seq', 'desc').limit(limit).execute(); }, + listAfter: (conversationId, userId, afterSeq, limit) => + visibleTo(userId) + .where('messages.conversation_id', '=', conversationId) + .where('messages.seq', '>', afterSeq) + .orderBy('messages.seq', 'asc') + .limit(limit) + .execute(), + + listContext: async (conversationId, userId, seq, half) => { + const above = await visibleTo(userId) + .where('messages.conversation_id', '=', conversationId) + .where('messages.seq', '<', seq) + .orderBy('messages.seq', 'desc') + .limit(half) + .execute(); + const fromTarget = await visibleTo(userId) + .where('messages.conversation_id', '=', conversationId) + .where('messages.seq', '>=', seq) + .orderBy('messages.seq', 'asc') + .limit(half) + .execute(); + return [...above, ...fromTarget]; + }, + listMedia: (conversationId, userId, kinds, beforeSeq, limit) => { let query = visibleTo(userId) .where('messages.conversation_id', '=', conversationId) @@ -257,5 +321,29 @@ export const createMessagesRepository = (db: Kysely): MessagesReposito } 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(), }; }; diff --git a/packages/backend/src/modules/messages/messages.routes.ts b/packages/backend/src/modules/messages/messages.routes.ts index 0d0833a..6d97ce1 100644 --- a/packages/backend/src/modules/messages/messages.routes.ts +++ b/packages/backend/src/modules/messages/messages.routes.ts @@ -3,13 +3,20 @@ import { sendMessageBodySchema, editMessageBodySchema, reactionBodySchema, + forwardMessageBodySchema, messageSchema, messageListSchema, mediaUrlSchema, mediaListQuerySchema, errorSchema, } 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 idParamsSchema = { @@ -38,17 +45,26 @@ const historyQuerySchema = { type: 'object', properties: { 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 }, }, } as const; +const contextQuerySchema = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 2, maximum: 100, default: 50 }, + }, +} as const; + export const messagesRoutes = (app: FastifyInstance): Promise => { - 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', { schema: { tags: ['messages'], - summary: 'Load conversation message history (newest first)', + summary: 'Load conversation message history (newest first; `after` pages downward)', security: bearerAuth, params: idParamsSchema, querystring: historyQuerySchema, @@ -59,6 +75,16 @@ export const messagesRoutes = (app: FastifyInstance): Promise => { async (request, reply) => { const user = request.authUser; 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 history = await app.messagesService.history( request.params.id, @@ -70,6 +96,33 @@ export const messagesRoutes = (app: FastifyInstance): Promise => { }, ); + 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 }>( '/conversations/:id/messages', { @@ -157,6 +210,92 @@ export const messagesRoutes = (app: FastifyInstance): Promise => { }, ); + app.post<{ Params: { id: string }; Body: ForwardMessageBody }>( + '/conversations/:id/forward', + { + schema: { + tags: ['messages'], + summary: 'Forward a message into this conversation', + security: bearerAuth, + params: idParamsSchema, + body: forwardMessageBodySchema, + response: { 200: messageSchema, 400: errorSchema, 401: errorSchema, 403: errorSchema, 404: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + const message = await app.messagesService.forward( + request.params.id, + user.id, + request.body.sourceConversationId, + request.body.messageId, + request.body.hideSender ?? false, + ); + return reply.send(message); + }, + ); + + app.get<{ Params: { id: string } }>( + '/conversations/:id/pinned', + { + schema: { + tags: ['messages'], + summary: 'List pinned messages (newest pin first)', + security: bearerAuth, + params: idParamsSchema, + response: { 200: messageListSchema, 401: errorSchema, 403: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + return reply.send(await app.messagesService.pinned(request.params.id, user.id)); + }, + ); + + app.post<{ Params: { id: string; messageId: string } }>( + '/conversations/:id/messages/:messageId/pin', + { + schema: { + tags: ['messages'], + summary: 'Pin a message', + security: bearerAuth, + params: messageParamsSchema, + response: { 401: errorSchema, 403: errorSchema, 404: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + await app.messagesService.pin(request.params.id, request.params.messageId, user.id); + return reply.code(204).send(); + }, + ); + + app.delete<{ Params: { id: string; messageId: string } }>( + '/conversations/:id/messages/:messageId/pin', + { + schema: { + tags: ['messages'], + summary: 'Unpin a message', + security: bearerAuth, + params: messageParamsSchema, + response: { 401: errorSchema, 403: errorSchema, 404: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + await app.messagesService.unpin(request.params.id, request.params.messageId, user.id); + return reply.code(204).send(); + }, + ); + app.get<{ Params: { id: string }; Querystring: { tab: MediaTab; before?: number; limit: number } }>( '/conversations/:id/media', { diff --git a/packages/backend/src/modules/messages/messages.service.ts b/packages/backend/src/modules/messages/messages.service.ts index 4263d7b..1283cd5 100644 --- a/packages/backend/src/modules/messages/messages.service.ts +++ b/packages/backend/src/modules/messages/messages.service.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { EventType, userChannel } from '@altricade/core'; import type { Message, @@ -5,9 +6,11 @@ import type { MessageEditEvent, MessageDeleteEvent, MessageHiddenEvent, + MessagePinEvent, ReactionEvent, SendMessageBody, MediaTab, + ForwardOrigin, } from '@altricade/core'; import { HttpError } from '../../shared/http-error'; import type { Publisher } from '../../shared/publisher'; @@ -27,11 +30,12 @@ export interface MessagesServiceDeps { 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 = { - media: ['image', 'video', 'video_note'], + media: ['image', 'video'], files: ['file'], - voice: ['voice'], + voice: ['voice', 'video_note'], }; export interface SentMessage { @@ -47,6 +51,19 @@ export interface MessagesService { beforeSeq: number | null, limit: number, ): Promise; + historyAfter( + conversationId: string, + userId: string, + afterSeq: number, + limit: number, + ): Promise; + /** Window centered on a message — jump-to-message / search result context. */ + context( + conversationId: string, + userId: string, + messageId: string, + limit: number, + ): Promise; edit( conversationId: string, messageId: string, @@ -55,6 +72,16 @@ export interface MessagesService { ): Promise; remove(conversationId: string, messageId: string, userId: string): Promise; hide(conversationId: string, messageId: string, userId: string): Promise; + forward( + targetConversationId: string, + userId: string, + sourceConversationId: string, + messageId: string, + hideSender: boolean, + ): Promise; + pin(conversationId: string, messageId: string, userId: string): Promise; + unpin(conversationId: string, messageId: string, userId: string): Promise; + pinned(conversationId: string, userId: string): Promise; media( conversationId: string, userId: string, @@ -101,9 +128,30 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic return toMessage(row, reactions.get(messageId) ?? []); }; + // Channels are broadcast: only the owner and admins may post. + const assertCanPost = async (conversationId: string, userId: string): Promise => { + 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 { send: async (conversationId, senderId, input) => { 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({ conversationId, @@ -114,6 +162,9 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic encryption: input.encryption ?? null, mediaKey: input.mediaKey ?? null, media: input.media ?? null, + replyToId, + forwarded: false, + forwardedFrom: null, }); const id = @@ -148,6 +199,34 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic return rows.map((row) => toMessage(row, reactions.get(row.id) ?? [])); }, + historyAfter: async (conversationId, userId, afterSeq, limit) => { + await assertMember(conversationId, userId); + const rows = await messages.listAfter(conversationId, userId, afterSeq, limit); + const reactions = await messages.reactionsFor( + rows.map((row) => row.id), + userId, + ); + return rows.map((row) => toMessage(row, reactions.get(row.id) ?? [])); + }, + + context: async (conversationId, userId, messageId, limit) => { + await assertMember(conversationId, userId); + const target = await messages.getWithSenderById(messageId); + if (target === undefined) { + throw new HttpError(404, 'not_found', 'Message not found'); + } + if (target.conversation_id !== conversationId) { + throw new HttpError(404, 'not_found', 'Message not found'); + } + const half = Math.max(1, Math.floor(limit / 2)); + const rows = await messages.listContext(conversationId, userId, target.seq, half); + const reactions = await messages.reactionsFor( + rows.map((row) => row.id), + userId, + ); + return rows.map((row) => toMessage(row, reactions.get(row.id) ?? [])); + }, + media: async (conversationId, userId, tab, beforeSeq, limit) => { await assertMember(conversationId, userId); const rows = await messages.listMedia(conversationId, userId, TAB_KINDS[tab], beforeSeq, limit); @@ -172,10 +251,10 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic await assertMessageIn(conversationId, messageId); let deleted = await messages.softDelete(messageId, userId); 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 role = await conversations.getRole(conversationId, userId); - if (conversation?.type !== 'group' || role !== 'owner') { + if (conversation === undefined || conversation.type === 'direct' || role !== 'owner') { throw new HttpError( 403, 'not_deletable', @@ -201,6 +280,103 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic 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) => { await assertMember(conversationId, userId); await assertMessageIn(conversationId, messageId); diff --git a/packages/backend/src/modules/users/users.repository.ts b/packages/backend/src/modules/users/users.repository.ts index 8a5d53d..4ec855d 100644 --- a/packages/backend/src/modules/users/users.repository.ts +++ b/packages/backend/src/modules/users/users.repository.ts @@ -12,6 +12,7 @@ export interface NewUser { } export interface ProfilePatch { + username?: string; displayName?: string; email?: string | null; phone?: string | null; @@ -58,7 +59,13 @@ export const createUsersRepository = (db: Kysely): UsersRepository => .selectAll() .where('id', '!=', excludeUserId) .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') .limit(limit) @@ -66,6 +73,9 @@ export const createUsersRepository = (db: Kysely): UsersRepository => updateProfile: (id, patch) => { const values: Updateable = { updated_at: new Date() }; + if (patch.username !== undefined) { + values.username = patch.username; + } if (patch.displayName !== undefined) { values.display_name = patch.displayName; } diff --git a/packages/backend/src/modules/users/users.service.ts b/packages/backend/src/modules/users/users.service.ts index 5da2684..0d8fea9 100644 --- a/packages/backend/src/modules/users/users.service.ts +++ b/packages/backend/src/modules/users/users.service.ts @@ -42,7 +42,16 @@ export const createUsersService = ({ users, publish }: UsersServiceDeps): UsersS }, 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) { throw new HttpError(404, 'not_found', 'User not found'); } diff --git a/packages/backend/src/plugins/swagger.ts b/packages/backend/src/plugins/swagger.ts index 4905d6b..53c82b3 100644 --- a/packages/backend/src/plugins/swagger.ts +++ b/packages/backend/src/plugins/swagger.ts @@ -11,7 +11,7 @@ export const swaggerPlugin = fp( await app.register(swagger, { openapi: { info: { - title: 'Altricade API', + title: 'Zovi API', version: '0.1.0', description: 'Realtime chat & calls backend.', }, diff --git a/packages/core/src/api/conversations.ts b/packages/core/src/api/conversations.ts index 314bf25..915739d 100644 --- a/packages/core/src/api/conversations.ts +++ b/packages/core/src/api/conversations.ts @@ -3,7 +3,13 @@ import { conversationListSchema, conversationMemberListSchema, } 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 { compileValidator, parse, requestJson } from './http'; import type { ApiClientConfig } from './http'; @@ -33,6 +39,12 @@ export const createGroup = async ( ): Promise => parse(conversationV, await requestJson(config, 'POST', '/conversations', body)); +export const createChannel = async ( + config: ApiClientConfig, + body: CreateChannelBody, +): Promise => + parse(conversationV, await requestJson(config, 'POST', '/conversations/channel', body)); + export const listMembers = async ( config: ApiClientConfig, id: string, @@ -62,6 +74,14 @@ export const markRead = async ( 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 => + parse(conversationV, await requestJson(config, 'POST', `/conversations/${id}/avatar`, body)); + // "Clear history" (for me): hides all current messages; the chat stays listed. export const clearConversation = async (config: ApiClientConfig, id: string): Promise => { await requestJson(config, 'POST', `/conversations/${id}/clear`); diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index a512e1c..151a42b 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -19,6 +19,8 @@ export { getConversation, createDirect, createGroup, + createChannel, + setConversationAvatar, listMembers, addMember, removeMember, @@ -29,9 +31,14 @@ export { listContacts, addContact, removeContact } from './contacts'; export { sendMessage, getHistory, + getMessageContext, editMessage, deleteMessage, hideMessage, + forwardMessage, + pinMessage, + unpinMessage, + listPinned, listConversationMedia, addReaction, removeReaction, @@ -41,7 +48,7 @@ export type { HistoryOptions } from './messages'; export { markRead } from './conversations'; export { getPresence, heartbeat } from './presence'; export { getUploadUrl, getAvatarUploadUrl, getMediaUrl, uploadToUrl } from './media'; -export type { UploadTarget, AvatarTarget } from './media'; +export type { UploadTarget, AvatarTarget, UploadOptions } from './media'; export { getVapidPublicKey, registerDevice, diff --git a/packages/core/src/api/media.ts b/packages/core/src/api/media.ts index 5897a39..4778c96 100644 --- a/packages/core/src/api/media.ts +++ b/packages/core/src/api/media.ts @@ -42,14 +42,61 @@ export const getMediaUrl = async ( 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). -export const uploadToUrl = async (uploadUrl: string, file: Blob, mime: string): Promise => { - const response = await fetch(uploadUrl, { - method: 'PUT', - headers: { 'content-type': mime }, - body: file, +// XHR instead of fetch: fetch cannot report upload progress, and XHR is +// available in both browsers and React Native. +export const uploadToUrl = ( + uploadUrl: string, + file: Blob, + mime: string, + options: UploadOptions = {}, +): Promise => + 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)}`); - } -}; diff --git a/packages/core/src/api/messages.ts b/packages/core/src/api/messages.ts index d00b04d..b2e4925 100644 --- a/packages/core/src/api/messages.ts +++ b/packages/core/src/api/messages.ts @@ -1,5 +1,11 @@ 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 { compileValidator, parse, requestJson } from './http'; import type { ApiClientConfig } from './http'; @@ -16,6 +22,8 @@ export const sendMessage = async ( export interface HistoryOptions { before?: number; + /** Ascending page after this seq (downward pagination from a jumped context). */ + after?: number; limit?: number; } @@ -28,6 +36,9 @@ export const getHistory = async ( if (options.before !== undefined) { params.set('before', String(options.before)); } + if (options.after !== undefined) { + params.set('after', String(options.after)); + } if (options.limit !== undefined) { params.set('limit', String(options.limit)); } @@ -37,6 +48,23 @@ export const getHistory = async ( 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 => + parse( + messageListV, + await requestJson( + config, + 'GET', + `/conversations/${conversationId}/messages/${messageId}/context?limit=${String(limit)}`, + ), + ); + export const editMessage = async ( config: ApiClientConfig, conversationId: string, @@ -66,6 +94,41 @@ export const hideMessage = async ( 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 => + parse( + messageV, + await requestJson(config, 'POST', `/conversations/${targetConversationId}/forward`, body), + ); + +export const pinMessage = async ( + config: ApiClientConfig, + conversationId: string, + messageId: string, +): Promise => { + await requestJson(config, 'POST', `/conversations/${conversationId}/messages/${messageId}/pin`); +}; + +export const unpinMessage = async ( + config: ApiClientConfig, + conversationId: string, + messageId: string, +): Promise => { + 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 => + parse(messageListV, await requestJson(config, 'GET', `/conversations/${conversationId}/pinned`)); + // Shared media for the profile panel, newest first, keyset-paginated by seq. export const listConversationMedia = async ( config: ApiClientConfig, diff --git a/packages/core/src/events/index.ts b/packages/core/src/events/index.ts index c4d22c1..3c15fad 100644 --- a/packages/core/src/events/index.ts +++ b/packages/core/src/events/index.ts @@ -20,6 +20,8 @@ export const EventType = { MessageDelete: 'message.delete', ReactionAdd: 'reaction.add', ReactionRemove: 'reaction.remove', + MessagePin: 'message.pin', + MessageUnpin: 'message.unpin', ReadReceipt: 'read.receipt', LastSeen: 'last_seen', ConversationNew: 'conversation.new', @@ -95,6 +97,13 @@ export interface ReadReceiptEvent { 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 { type: typeof EventType.TypingStart | typeof EventType.TypingStop; conversationId: string; diff --git a/packages/core/src/schemas/auth.ts b/packages/core/src/schemas/auth.ts index 5cae719..018eeec 100644 --- a/packages/core/src/schemas/auth.ts +++ b/packages/core/src/schemas/auth.ts @@ -37,6 +37,7 @@ export const updateMeBodySchema = { additionalProperties: false, minProperties: 1, properties: { + username: { type: 'string', pattern: USERNAME_PATTERN }, displayName: { type: 'string', minLength: 1, maxLength: 64 }, // `null` clears the value; a string sets it. email: { type: ['string', 'null'], format: 'email', maxLength: 254 }, diff --git a/packages/core/src/schemas/conversation.ts b/packages/core/src/schemas/conversation.ts index 47c280d..34ce41a 100644 --- a/packages/core/src/schemas/conversation.ts +++ b/packages/core/src/schemas/conversation.ts @@ -26,6 +26,23 @@ export const createGroupBodySchema = { }, } 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 = { type: 'object', additionalProperties: false, @@ -48,6 +65,21 @@ export const sendMessageBodySchema = { // Object key returned by the media upload URL + its metadata. mediaKey: { type: 'string', minLength: 1, maxLength: 255 }, 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; @@ -60,8 +92,10 @@ export const addContactBodySchema = { }, } as const; +export type ForwardMessageBody = FromSchema; export type CreateDirectBody = FromSchema; export type CreateGroupBody = FromSchema; +export type CreateChannelBody = FromSchema; export type AddMemberBody = FromSchema; export type SendMessageBody = FromSchema; export type AddContactBody = FromSchema; diff --git a/packages/core/src/schemas/entities.ts b/packages/core/src/schemas/entities.ts index df79f06..69c55da 100644 --- a/packages/core/src/schemas/entities.ts +++ b/packages/core/src/schemas/entities.ts @@ -101,6 +101,8 @@ export const conversationSchema = { 'id', 'type', 'title', + 'description', + 'avatarUrl', 'peer', 'createdBy', 'createdAt', @@ -109,8 +111,10 @@ export const conversationSchema = { ], properties: { id: { type: 'string', format: 'uuid' }, - type: { type: 'string', enum: ['direct', 'group'] }, + type: { type: 'string', enum: ['direct', 'group', 'channel'] }, title: { type: ['string', 'null'] }, + description: { type: ['string', 'null'] }, + avatarUrl: { type: ['string', 'null'] }, peer: { oneOf: [publicUserSchema, { type: 'null' }], }, @@ -177,6 +181,29 @@ export const contactSchema = { 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 = { type: 'object', additionalProperties: false, @@ -195,6 +222,9 @@ export const messageSchema = { 'deletedAt', 'reactions', 'media', + 'replyTo', + 'forwarded', + 'forwardedFrom', ], properties: { id: { type: 'string', format: 'uuid' }, @@ -211,6 +241,9 @@ export const messageSchema = { deletedAt: { type: ['string', 'null'], format: 'date-time' }, reactions: { type: 'array', items: reactionSummarySchema }, media: { oneOf: [mediaRefSchema, { type: 'null' }] }, + replyTo: { oneOf: [replyPreviewSchema, { type: 'null' }] }, + forwarded: { type: 'boolean' }, + forwardedFrom: { oneOf: [forwardOriginSchema, { type: 'null' }] }, }, } as const; diff --git a/packages/core/src/schemas/index.ts b/packages/core/src/schemas/index.ts index 725902b..587a67a 100644 --- a/packages/core/src/schemas/index.ts +++ b/packages/core/src/schemas/index.ts @@ -3,15 +3,19 @@ export type { RegisterBody, LoginBody, UpdateMeBody } from './auth'; export { createDirectBodySchema, createGroupBodySchema, + createChannelBodySchema, addMemberBodySchema, sendMessageBodySchema, + forwardMessageBodySchema, addContactBodySchema, } from './conversation'; export type { CreateDirectBody, CreateGroupBody, + CreateChannelBody, AddMemberBody, SendMessageBody, + ForwardMessageBody, AddContactBody, } from './conversation'; export { editMessageBodySchema, reactionBodySchema, readBodySchema } from './live'; diff --git a/packages/core/src/types/conversation.ts b/packages/core/src/types/conversation.ts index 2f7b31a..9882185 100644 --- a/packages/core/src/types/conversation.ts +++ b/packages/core/src/types/conversation.ts @@ -1,13 +1,19 @@ 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 { id: string; type: ConversationType; - /** Title for a group; null for a direct conversation. */ + /** Title for a group/channel; null for a direct conversation. */ 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; createdBy: string; createdAt: string; diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 0fd2c8e..f25567d 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -5,7 +5,7 @@ export type { User, PublicUser } from './user'; export type { AuthResult, Session, CentrifugoToken } from './auth'; export type { Conversation, ConversationType, ConversationMember } from './conversation'; 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 { ChatFolder, ChatPin, FoldersState } from './folders'; export type { diff --git a/packages/core/src/types/message.ts b/packages/core/src/types/message.ts index ddd8c70..91aeec7 100644 --- a/packages/core/src/types/message.ts +++ b/packages/core/src/types/message.ts @@ -10,6 +10,26 @@ export interface ReactionSummary { 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, // base64 ciphertext once E2EE lands. `seq` is the server-assigned monotonic order. export interface Message { @@ -27,4 +47,11 @@ export interface Message { deletedAt: string | null; reactions: ReactionSummary[]; 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; } diff --git a/packages/web/index.html b/packages/web/index.html index 29e09c7..ec416f7 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -3,7 +3,12 @@ - Altricade + Zovi + + + + +
diff --git a/packages/web/public/favicon.ico b/packages/web/public/favicon.ico new file mode 100644 index 0000000..88d25ee Binary files /dev/null and b/packages/web/public/favicon.ico differ diff --git a/packages/web/public/favicon.svg b/packages/web/public/favicon.svg new file mode 100644 index 0000000..18b123d --- /dev/null +++ b/packages/web/public/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/web/public/icons/apple-touch-icon.png b/packages/web/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..bee7c19 Binary files /dev/null and b/packages/web/public/icons/apple-touch-icon.png differ diff --git a/packages/web/public/icons/icon-192.png b/packages/web/public/icons/icon-192.png new file mode 100644 index 0000000..fe58861 Binary files /dev/null and b/packages/web/public/icons/icon-192.png differ diff --git a/packages/web/public/icons/icon-512.png b/packages/web/public/icons/icon-512.png new file mode 100644 index 0000000..e3140ca Binary files /dev/null and b/packages/web/public/icons/icon-512.png differ diff --git a/packages/web/public/manifest.webmanifest b/packages/web/public/manifest.webmanifest new file mode 100644 index 0000000..86b4ad8 --- /dev/null +++ b/packages/web/public/manifest.webmanifest @@ -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" + } + ] +} diff --git a/packages/web/public/sw.js b/packages/web/public/sw.js index d17c796..c000440 100644 --- a/packages/web/public/sw.js +++ b/packages/web/public/sw.js @@ -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). */ self.addEventListener('push', (event) => { @@ -14,6 +14,7 @@ self.addEventListener('push', (event) => { event.waitUntil( self.registration.showNotification(title, { body, + icon: '/icons/icon-192.png', // Collapse multiple messages from the same chat into one notification. tag: conversationId || undefined, renotify: Boolean(conversationId), diff --git a/packages/web/src/app/App.tsx b/packages/web/src/app/App.tsx index bf8cc45..d298a8e 100644 --- a/packages/web/src/app/App.tsx +++ b/packages/web/src/app/App.tsx @@ -1,15 +1,27 @@ 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 { heartbeat } from '@altricade/core/api'; +import { + heartbeat, + getAvatarUploadUrl, + uploadToUrl, + setConversationAvatar, + forwardMessage, +} from '@altricade/core/api'; import { SessionProvider, useSession } from '../entities/session'; import { AuthForm } from '../features/auth'; 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 { ContactsPanel } from '../features/contacts'; import { ChatView } from '../features/messaging'; -import { ProfilePanel } from '../features/profile'; +import { ProfilePanel, GroupInfoPanel } from '../features/profile'; import { SettingsPanel } from '../features/settings'; import { NotificationsProvider, useNotifications, unregisterWebPush } from '../features/notifications'; import { apiConfig } from '../shared/api'; @@ -19,15 +31,20 @@ import { UsersIcon, UserIcon, ChatsIcon, - SettingsIcon, ChevronLeftIcon, + MegaphoneIcon, + BrandGlyph, + ContextMenu, + MenuItem, + useContextMenu, } from '../shared/ui'; -type RailView = 'chats' | 'contacts' | 'compose' | 'settings'; +type RailView = 'chats' | 'contacts' | 'new-group' | 'new-channel' | 'settings'; const RAIL_TITLES: Record, string> = { contacts: 'Contacts', - compose: 'New group', + 'new-group': 'New group', + 'new-channel': 'New channel', settings: 'Settings', }; @@ -49,65 +66,6 @@ const mediaLabel = (media: MediaRef | null): string => { } }; -const GroupComposer = ({ - onCreate, -}: { - onCreate: (title: string, members: string[]) => Promise; -}): ReactElement => { - const [title, setTitle] = useState(''); - const [members, setMembers] = useState(''); - const [error, setError] = useState(null); - - const submit = async (event: SyntheticEvent): Promise => { - 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 ( -
-

New group

-
void submit(event)}> - { - setTitle(event.target.value); - }} - /> - { - setMembers(event.target.value); - }} - /> - -
- {error !== null ?

{error}

: null} -
- ); -}; - const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): ReactElement => { const me: PublicUser = { id: user.id, @@ -120,6 +78,8 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re const [railView, setRailView] = useState('chats'); const [activeFolderId, setActiveFolderId] = useState(null); const [profileUser, setProfileUser] = useState(null); + const [groupInfo, setGroupInfo] = useState(null); + const [forwarding, setForwarding] = useState(null); const convRef = useRef([]); const folders = useFolders(user.id); @@ -138,16 +98,35 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re [notify], ); - const { conversations, onlineMap, startDirect, createGroupChat, clearChat, deleteChat } = - useConversations(user.id, current?.id ?? null, onIncoming); + const { + conversations, + onlineMap, + startDirect, + createGroupChat, + createChannelChat, + clearChat, + deleteChat, + } = useConversations(user.id, current?.id ?? null, onIncoming); + const composeMenu = useContextMenu(); convRef.current = conversations; const openConversation = useCallback((conversation: Conversation): void => { setCurrent(conversation); setRailView('chats'); 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). const profileConversation = 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, ) ?? null); - // If the open chat disappears from the list (deleted for me, possibly on - // another device), close it. + // Keep the open chat in sync with the live list: close it if it disappears + // (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(() => { - 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); + } else if (updated !== current) { + setCurrent(updated); } }, [conversations, current]); @@ -211,14 +197,47 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re openConversation(await startDirect(username)); }; - const createGroupAndOpen = async (title: string, members: string[]): Promise => { - openConversation(await createGroupChat(title, members)); + // Create → optionally upload the chosen photo → open, so the new chat lands + // with its avatar already set. + const applyAvatar = async (conversation: Conversation, avatar: File | null): Promise => { + 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 => { + openConversation(await applyAvatar(await createGroupChat(title, members), avatar)); + }; + + const createChannelAndOpen = async ( + title: string, + description: string | null, + members: string[], + avatar: File | null, + ): Promise => { + 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 ? ( + + ) : ( + {user.displayName.charAt(0)} + ); + const shellClass = [ 'app-shell', current !== null ? 'has-chat' : '', - profileUser !== null ? 'has-profile' : '', + profileUser !== null || groupInfo !== null ? 'has-profile' : '', ] .filter((token) => token !== '') .join(' '); @@ -230,9 +249,9 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re {railView === 'chats' ? ( - Altricade + Zovi ) : ( @@ -251,41 +270,17 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re )}
{railView === 'chats' ? ( - <> - - - - + ) : ( {current !== null ? ( { - if (target.id !== user.id) { - setProfileUser(target); - } + onOpenProfile={openUserProfile} + onOpenGroupInfo={(conv) => { + setProfileUser(null); + setGroupInfo(conv); + }} + onForward={(msgs) => { + setForwarding(msgs); }} onBack={() => { setCurrent(null); @@ -387,7 +407,7 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re

Select a conversation

Search for someone or start a new chat to begin messaging.

@@ -407,6 +427,40 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re void startDirectAndOpen(username); }} /> + ) : groupInfo !== null ? ( + { + setGroupInfo(null); + }} + onOpenUser={openUserProfile} + /> + ) : null} + + {forwarding !== null ? ( + { + 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}
); @@ -419,7 +473,7 @@ const Shell = (): ReactElement => { return (
); diff --git a/packages/web/src/app/index.css b/packages/web/src/app/index.css index a68e946..e0f36c7 100644 --- a/packages/web/src/app/index.css +++ b/packages/web/src/app/index.css @@ -1,5 +1,5 @@ /* ============================================================================= - Altricade — design system foundation + Zovi — design system foundation Colors come from ThemeProvider as --color- (see shared/theme/tokens.ts). Everything below is the static primitive layer + base element styling. Component sections are appended further down, grouped by feature. @@ -1086,17 +1086,26 @@ a { opacity: 0.7; } +/* Timestamp on its own line under the content, right-aligned — consistent + across text, media, voice, and round video (no more cramped inline times). */ .bubble-meta { - float: right; - display: inline-flex; + display: flex; align-items: center; - gap: 0.2rem; - margin: 0.35rem 0 -0.1rem 0.6rem; + /* Time (left) … read mark (right). */ + justify-content: space-between; + gap: 0.75rem; + margin-top: 0.2rem; font-size: 0.68rem; line-height: 1; user-select: none; } +.meta-left { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + .bubble-mine .bubble-meta { color: var(--color-onAccentMuted); } @@ -1722,7 +1731,14 @@ a { .bubble.bubble-bare .bubble-meta { color: var(--color-textFaint); padding: 0; - margin-top: 0.3rem; + margin-top: 0.35rem; +} + +/* Extra breathing room so stacked round-video notes (and their times) don't + crowd each other. */ +.msg-line.has-bare { + margin-top: 0.35rem; + margin-bottom: 0.6rem; } /* ============================================================================= @@ -2321,23 +2337,88 @@ a { background: var(--color-surfaceHover); } -.profile-voice { - display: flex; - align-items: center; - gap: 0.6rem; - padding: 0.35rem 0.5rem; - border-radius: var(--radius-md); - background: var(--color-background); - border: 1px solid var(--color-border); +/* Telegram-style rows in the Voice tab: round thumb, date, sender. */ +.note-month { + margin: 0.65rem 0 0.15rem; + padding: 0 0.25rem; + font-size: var(--text-xs); + font-weight: 600; + letter-spacing: 0.05em; } -.profile-voice .voice { - flex: 1; +.note-row { + display: flex; + align-items: center; + gap: 0.65rem; + width: 100%; + padding: 0.4rem 0.5rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-background); + color: var(--color-text); + text-align: left; +} + +.note-row-click { + cursor: pointer; + transition: background var(--dur-fast) var(--ease); +} + +.note-row-click:hover { + background: var(--color-surfaceHover); +} + +.note-play { + flex: 0 0 auto; + display: grid; + place-items: center; + width: 42px; + height: 42px; + border: none; + border-radius: var(--radius-full); + background: var(--color-accent); + color: #fff; + cursor: pointer; +} + +.note-thumb { + position: relative; + flex: 0 0 auto; + width: 42px; + height: 42px; + border-radius: var(--radius-full); + overflow: hidden; + background: #000; +} + +.note-thumb video { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.note-thumb-play { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: #fff; + background: rgba(0, 0, 0, 0.25); +} + +.note-meta { + display: flex; + flex-direction: column; min-width: 0; } -.profile-voice-date { - flex: 0 0 auto; +.note-date { + font-weight: 600; + font-size: var(--text-sm); +} + +.note-sender { font-size: var(--text-xs); } @@ -2606,8 +2687,45 @@ a { Mobile bottom navigation (Telegram-style) + profile back button ========================================================================== */ +/* Bottom nav toolbar — Telegram-style, always visible (desktop + mobile). */ .rail-nav { - display: none; + display: flex; + align-items: stretch; + flex: 0 0 auto; + border-top: 1px solid var(--color-border); + background: var(--color-surfacePanel); + padding-bottom: env(safe-area-inset-bottom); +} + +.rail-nav-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 0.95rem 0; + border: none; + background: none; + color: var(--color-textFaint); + transition: color var(--dur-fast) var(--ease); +} + +.rail-nav-btn:hover { + color: var(--color-textMuted); +} + +.rail-nav-btn[aria-pressed='true'] { + color: var(--color-accent); +} + +.rail-nav-icon { + position: relative; + display: inline-flex; +} + +.rail-nav-badge { + position: absolute; + top: -6px; + right: -12px; } .profile-back { @@ -2615,47 +2733,6 @@ a { } @media (max-width: 900px) { - .rail-nav { - display: flex; - align-items: stretch; - flex: 0 0 auto; - border-top: 1px solid var(--color-border); - background: var(--color-surfacePanel); - padding-bottom: env(safe-area-inset-bottom); - } - - .rail-nav-btn { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - padding: 0.65rem 0; - border: none; - background: none; - color: var(--color-textFaint); - transition: color var(--dur-fast) var(--ease); - } - - .rail-nav-btn[aria-pressed='true'] { - color: var(--color-accent); - } - - .rail-nav-icon { - position: relative; - display: inline-flex; - } - - .rail-nav-badge { - position: absolute; - top: -6px; - right: -12px; - } - - /* Header shortcuts are redundant next to the bottom nav. */ - .rail-desktop-action { - display: none; - } - /* Profile page: back arrow left (Telegram-style), no X. */ .profile-back { display: inline-flex; @@ -2679,3 +2756,1034 @@ a { right: 0.75rem; } } + +/* ============================================================================= + User picker (Telegram "Select Users") + creation wizards + ========================================================================== */ + +.picker { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; +} + +.picker-search { + padding: 0.35rem 0.85rem 0.5rem; +} + +.pick-chips { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + padding: 0.25rem 0.85rem 0; +} + +.pick-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.2rem 0.6rem; + border: none; + border-radius: var(--radius-full); + background: var(--color-accentSoft); + color: var(--color-accent); + font-size: var(--text-sm); + font-weight: 600; + transition: background var(--dur-fast) var(--ease); +} + +.pick-chip:hover { + background: color-mix(in srgb, var(--color-danger) 14%, transparent); + color: var(--color-danger); +} + +.pick-list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0 0.5rem 0.75rem; +} + +.pick-row { + display: flex; + align-items: center; + gap: 0.7rem; + width: 100%; + padding: 0.45rem 0.6rem; + border: none; + border-radius: var(--radius-lg); + background: none; + text-align: left; + transition: background var(--dur-fast) var(--ease); +} + +button.pick-row:hover { + background: var(--color-surfaceHover); +} + +.pick-avatar { + width: 40px; + height: 40px; + font-size: var(--text-base); +} + +.pick-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + line-height: 1.3; +} + +.pick-name { + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pick-sub { + font-size: var(--text-sm); + color: var(--color-textMuted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pick-check { + flex: 0 0 auto; + display: grid; + place-items: center; + width: 22px; + height: 22px; + border-radius: var(--radius-full); + border: 2px solid var(--color-borderStrong); + color: var(--color-onAccent); + transition: + background var(--dur-fast) var(--ease), + border-color var(--dur-fast) var(--ease); +} + +.pick-check.is-on { + background: var(--color-accent); + border-color: var(--color-accent); +} + +/* Wizards (new group / new channel) */ +.wizard { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.wizard-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.35rem 0.85rem 0.5rem; + flex: 0 0 auto; +} + +.wizard-bar-actions { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.wizard-count { + font-size: var(--text-sm); + font-weight: 600; + color: var(--color-textMuted); +} + +.wizard-form { + display: flex; + flex-direction: column; + gap: 0.9rem; + padding: 1.25rem 1rem; +} + +.wizard-glyph { + align-self: center; + display: grid; + place-items: center; + width: 64px; + height: 64px; + border-radius: var(--radius-full); + background: linear-gradient(140deg, var(--color-accent), var(--color-accentHover)); + color: var(--color-onAccent); + margin-bottom: 0.25rem; +} + +.wizard-desc { + resize: vertical; + min-height: 4.2rem; + font: inherit; +} + +.wizard-note { + font-size: var(--text-sm); +} + +.wizard-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} + +.btn-ghost { + padding: 0.55rem 0.9rem; + border: none; + border-radius: var(--radius-md); + background: none; + color: var(--color-textMuted); + font-size: var(--text-sm); + font-weight: 600; + transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease); +} + +.btn-ghost:hover { + background: var(--color-surfaceHover); + color: var(--color-text); +} + +/* Channel read-only footer (subscribers) */ +.channel-readonly { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.85rem; + background: var(--color-surfacePanel); + border-top: 1px solid var(--color-border); + color: var(--color-textMuted); + font-size: var(--text-sm); + font-weight: 600; +} + +/* Contacts typeahead rows */ +.contact-match { + padding-right: 0.25rem; +} + +.contact-match .contact-open { + flex: 1; + min-width: 0; + padding: 0.15rem 0.25rem; +} + +.contact-add { + width: 34px; + height: 34px; + color: var(--color-accent); +} + +.contact-add:hover { + background: var(--color-accentSoft); + color: var(--color-accent); +} + +.contact-added { + display: grid; + place-items: center; + width: 34px; + height: 34px; + color: var(--color-online); +} + +/* ============================================================================= + Settings page v2 — Telegram-style grouped, editable rows + ========================================================================== */ + +.settings-name-display { + font-size: var(--text-lg); + font-weight: 700; +} + +.set-group { + padding: 0 0.85rem; + margin-top: 0.85rem; +} + +.set-card { + border-radius: var(--radius-lg); + background: var(--color-background); + border: 1px solid var(--color-border); + overflow: hidden; +} + +.set-row { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.1rem; + width: 100%; + padding: 0.6rem 0.9rem; + border: none; + background: none; + text-align: left; + color: var(--color-text); + transition: background var(--dur-fast) var(--ease); +} + +.set-card > .set-row + .set-row, +.set-card > .set-row-edit + .set-row, +.set-card > .set-row + .set-row-edit { + border-top: 1px solid var(--color-border); +} + +button.set-row:hover { + background: var(--color-surfaceHover); + cursor: pointer; +} + +.set-value { + font-size: var(--text-base); +} + +.set-placeholder { + color: var(--color-accent); +} + +.set-label { + font-size: var(--text-xs); + color: var(--color-textFaint); +} + +.set-row-static { + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.set-row-static .set-label { + order: -1; + font-size: var(--text-base); + color: var(--color-text); +} + +.set-row-edit { + gap: 0.35rem; + padding: 0.5rem 0.6rem 0.6rem 0.9rem; +} + +.set-input-wrap { + display: flex; + align-items: center; + gap: 0.25rem; + width: 100%; +} + +.set-prefix { + color: var(--color-textMuted); +} + +.set-input { + flex: 1; + min-width: 0; + border: none; + background: none; + color: var(--color-text); + font: inherit; + padding: 0.25rem 0; +} + +.set-input:focus { + outline: none; +} + +.set-row-btn { + width: 32px; + height: 32px; +} + +.set-row-error { + color: var(--color-danger); + font-size: var(--text-xs); +} + +.set-card .settings-logout { + border-radius: 0; +} + +/* ============================================================================= + Avatar-as-Settings entry (desktop header + mobile bottom nav) + ========================================================================== */ + +.rail-avatar-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + flex: 0 0 auto; + padding: 0; + border: none; + border-radius: var(--radius-full); + background: none; + cursor: pointer; + transition: box-shadow var(--dur-fast) var(--ease), transform var(--dur-fast) var(--ease); +} + +.rail-avatar-btn:hover { + transform: scale(1.05); +} + +.rail-avatar { + width: 32px; + height: 32px; + font-size: var(--text-sm); +} + +/* Mobile bottom-nav avatar tab */ +.rail-nav-avatar-img { + width: 30px; + height: 30px; + font-size: var(--text-sm); + box-shadow: 0 0 0 2px transparent; + transition: box-shadow var(--dur-fast) var(--ease); +} + +.rail-nav-avatar-btn[aria-pressed='true'] .rail-nav-avatar-img { + box-shadow: 0 0 0 2px var(--color-accent); +} + +.contact-online { + color: var(--color-online); +} + +/* ============================================================================= + Group/channel info — members list + ========================================================================== */ + +.profile-about { + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.member-row { + display: flex; + align-items: center; + gap: 0.7rem; + width: 100%; + padding: 0.4rem 0.5rem; + border: none; + border-radius: var(--radius-md); + background: none; + text-align: left; + transition: background var(--dur-fast) var(--ease); +} + +.member-row:hover { + background: var(--color-surfaceHover); +} + +.member-avatar { + width: 40px; + height: 40px; + font-size: var(--text-base); +} + +.member-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + line-height: 1.3; +} + +.member-name { + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.member-sub { + font-size: var(--text-sm); + color: var(--color-textMuted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.member-role { + flex: 0 0 auto; + font-size: var(--text-xs); + font-weight: 600; + color: var(--color-accent); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +/* ============================================================================= + Hero avatar (view full-size + optional edit) + wizard avatar picker + ========================================================================== */ + +.hero-avatar-wrap { + position: relative; + margin-bottom: 0.6rem; +} + +.hero-avatar-view { + display: block; + padding: 0; + border: none; + background: none; + border-radius: var(--radius-full); + cursor: zoom-in; +} + +.hero-avatar-edit { + position: absolute; + right: 2px; + bottom: 2px; + display: grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: var(--radius-full); + background: var(--color-accent); + color: var(--color-onAccent); + border: 2px solid var(--color-surfacePanel); + cursor: pointer; +} + +/* Round video note inside the profile Voice tab — small player. */ +.profile-videonote .round-video { + width: 56px; + height: 56px; +} + +.profile-videonote .round-video-overlay svg, +.profile-videonote .round-video-time { + transform: scale(0.8); +} + +/* Wizard avatar picker (creation flows) */ +.wizard-name-row { + display: flex; + align-items: flex-end; + gap: 0.75rem; +} + +.wizard-name-field { + flex: 1; + min-width: 0; +} + +.wizard-avatar { + position: relative; + flex: 0 0 auto; + width: 56px; + height: 56px; + border-radius: var(--radius-full); + cursor: pointer; + overflow: visible; +} + +.wizard-avatar-img { + width: 56px; + height: 56px; + border-radius: var(--radius-full); + object-fit: cover; + display: block; +} + +.wizard-avatar-ph { + display: grid; + place-items: center; + width: 56px; + height: 56px; + border-radius: var(--radius-full); + background: var(--color-surface); + color: var(--color-textMuted); + border: 1px dashed var(--color-borderStrong); +} + +.wizard-avatar-cam { + position: absolute; + right: -2px; + bottom: -2px; + display: grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: var(--radius-full); + background: var(--color-accent); + color: var(--color-onAccent); + border: 2px solid var(--color-surfacePanel); +} + +/* ============================================================================= + Reply, forward, pin, select + ========================================================================== */ + +/* Pinned bar (below the chat header) */ +.pin-bar { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.8rem; + background: color-mix(in srgb, var(--color-surfacePanel) 90%, transparent); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--color-border); +} + +.pin-bar-icon { + color: var(--color-accent); + flex: 0 0 auto; +} + +.pin-bar-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 1px; + padding: 0.1rem 0.3rem; + border: none; + border-left: 2px solid var(--color-accent); + padding-left: 0.6rem; + background: none; + text-align: left; + line-height: 1.25; +} + +.pin-bar-label { + font-size: var(--text-xs); + font-weight: 700; + color: var(--color-accent); +} + +.pin-bar-preview { + font-size: var(--text-sm); + color: var(--color-textMuted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.pin-bar-unpin { + width: 32px; + height: 32px; + flex: 0 0 auto; +} + +.bubble-pin { + opacity: 0.75; +} + +/* Reply quote inside a bubble */ +.reply-quote { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 1px; + width: 100%; + margin: 0 0 0.25rem; + padding: 0.2rem 0.5rem; + border: none; + border-left: 2px solid currentColor; + border-radius: 4px; + background: color-mix(in srgb, currentColor 10%, transparent); + text-align: left; + cursor: pointer; + line-height: 1.3; +} + +.reply-quote-name { + font-size: var(--text-xs); + font-weight: 700; + opacity: 0.95; +} + +.reply-quote-text { + font-size: var(--text-sm); + opacity: 0.8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 260px; +} + +/* Forwarded-from header — its own line above the content, name not clipped. */ +.fwd-head { + display: flex; + align-items: center; + gap: 0.3rem; + width: 100%; + margin-bottom: 0.25rem; + padding: 0; + border: none; + background: none; + text-align: left; + font-size: var(--text-xs); + font-weight: 600; + color: var(--color-accent); +} + +.bubble-mine .fwd-head { + color: var(--color-onAccent); +} + +.fwd-name { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.fwd-link { + cursor: pointer; +} + +.fwd-link:hover .fwd-name { + text-decoration: underline; +} + +/* Forwarded header above a bare round-video note (no bubble background). */ +.fwd-bare { + color: var(--color-accent); + padding: 0 0.1rem 0.1rem; +} + +/* Jump-to-message highlight — Telegram-style wash over the whole row. */ +.msg-line.flash { + animation: flash-line 1.6s var(--ease); + border-radius: var(--radius-md); +} + +@keyframes flash-line { + 0%, + 100% { + background: transparent; + } + 15%, + 55% { + background: color-mix(in srgb, var(--color-accent) 16%, transparent); + } +} + +/* Selection mode */ +.msg-line.select-mode { + cursor: pointer; + padding-left: 0.25rem; +} + +.msg-line.select-mode.selected { + background: color-mix(in srgb, var(--color-accent) 12%, transparent); + border-radius: var(--radius-md); +} + +.select-dot { + flex: 0 0 auto; + align-self: center; + display: grid; + place-items: center; + width: 22px; + height: 22px; + border-radius: var(--radius-full); + border: 2px solid var(--color-borderStrong); + color: var(--color-onAccent); +} + +.select-dot.is-on { + background: var(--color-accent); + border-color: var(--color-accent); +} + +.select-bar { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem clamp(0.6rem, 3vw, 1.5rem); + background: var(--color-surfacePanel); + border-top: 1px solid var(--color-border); +} + +.select-count { + flex: 1; + font-weight: 600; + color: var(--color-textMuted); +} + +.select-actions { + display: flex; + gap: 0.35rem; +} + +.select-actions .icon-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Non-interactive info row in the message context menu */ +.ctx-info { + padding: 0.4rem 0.7rem; + font-size: var(--text-xs); + color: var(--color-textFaint); + border-top: 1px solid var(--color-border); + margin-top: 4px; + font-variant-numeric: tabular-nums; +} + +/* Forward picker modal */ +.forward-panel { + display: flex; + flex-direction: column; + width: min(400px, calc(100vw - 2rem)); + max-height: min(560px, 82vh); + border-radius: var(--radius-lg); + background: var(--color-surfacePanel); + border: 1px solid var(--color-border); + box-shadow: var(--shadow-lg); + overflow: hidden; +} + +.forward-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.6rem 0.5rem 1rem; + border-bottom: 1px solid var(--color-border); +} + +.forward-title { + font-weight: 700; +} + +.forward-hide { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 1rem 0.2rem; + font-size: var(--text-sm); + color: var(--color-textMuted); + cursor: pointer; +} + +.forward-list { + flex: 1; + min-height: 0; +} + +/* ============================================================================= + Deterministic media boxes — the frame owns the size (from stored dimensions + or a legacy fixed box); the media fills it. Layout never reflows on load, + which is what keeps scroll positions exact. + ========================================================================== */ + +.media-image-btn, +.media-video-frame { + max-width: min(340px, 72vw); + overflow: hidden; +} + +.media-fill { + width: 100% !important; + height: 100% !important; + max-width: none; + max-height: none; + object-fit: cover; + display: block; + border-radius: var(--radius-md); +} + +/* Media sent before dimensions were captured: fixed cover-cropped box. */ +.media-image-btn.media-box-legacy { + width: min(300px, 68vw); + height: 220px; +} + +.media-video-frame.media-box-legacy { + width: min(320px, 72vw); + height: 200px; +} + +/* ============================================================================= + Media upload progress (pending sends) + ========================================================================== */ + +.upload-bubble { + min-width: 200px; +} + +/* --- image/video preview with overlaid ring --- */ + +.upload-media { + position: relative; + border-radius: var(--radius-md); + overflow: hidden; + max-width: min(260px, 60vw); +} + +.upload-media-preview { + display: block; + width: 100%; + max-height: 260px; + object-fit: cover; +} + +.upload-media-shade { + position: absolute; + inset: 0; + background: rgb(0 0 0 / 45%); +} + +.upload-media-overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.45rem; + color: #fff; +} + +.upload-status { + font-size: var(--text-xs); + font-weight: 600; + text-shadow: 0 1px 2px rgb(0 0 0 / 45%); +} + +.upload-media.is-failed .upload-status { + color: #fca5a5; +} + +/* --- voice/file row with the ring in the icon slot --- */ + +.upload-row { + display: flex; + align-items: center; + gap: 0.65rem; + min-width: 220px; + padding: 0.15rem 0; +} + +.upload-row-meta { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.upload-row-name { + font-size: var(--text-sm); + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.upload-row-sub { + font-size: var(--text-xs); + opacity: 0.75; +} + +.upload-row.is-failed .upload-row-sub { + color: var(--color-danger); + opacity: 1; +} + +.upload-row-kind { + flex: 0 0 auto; + opacity: 0.7; + display: inline-flex; +} + +/* --- progress ring with cancel inside / sending spinner / retry actions --- */ + +.upload-ring-btn { + position: relative; + width: 40px; + height: 40px; + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + border-radius: 50%; + background: color-mix(in srgb, currentColor 12%, transparent); + color: inherit; + cursor: pointer; +} + +.upload-ring-btn.is-busy { + cursor: default; +} + +.upload-media-overlay .upload-ring-btn { + background: rgb(0 0 0 / 40%); + color: #fff; +} + +.upload-ring { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + transform: rotate(-90deg); +} + +.upload-ring-track, +.upload-ring-fill { + fill: none; + stroke-width: 2.5; +} + +.upload-ring-track { + stroke: color-mix(in srgb, currentColor 28%, transparent); +} + +.upload-ring-fill { + stroke: currentColor; + stroke-linecap: round; + transition: stroke-dashoffset 0.2s linear; +} + +.upload-ring-x { + position: relative; +} + +.upload-spinner { + width: 20px; + height: 20px; + border-radius: 50%; + border: 2.5px solid color-mix(in srgb, currentColor 25%, transparent); + border-top-color: currentColor; + animation: upload-spin 0.8s linear infinite; +} + +@keyframes upload-spin { + to { + transform: rotate(360deg); + } +} + +.upload-actions { + display: inline-flex; + align-items: center; + gap: 0.25rem; + flex: 0 0 auto; +} + +.upload-media-overlay .upload-action { + color: #fff; + background: rgb(0 0 0 / 40%); + border-radius: 50%; +} diff --git a/packages/web/src/features/auth/ui/AuthForm.tsx b/packages/web/src/features/auth/ui/AuthForm.tsx index 170eb00..988bd9d 100644 --- a/packages/web/src/features/auth/ui/AuthForm.tsx +++ b/packages/web/src/features/auth/ui/AuthForm.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import type { ReactElement, SyntheticEvent } from 'react'; import { ApiError } from '@altricade/core/api'; import { useSession } from '../../../entities/session'; +import { BrandGlyph } from '../../../shared/ui'; type Mode = 'login' | 'register'; @@ -36,9 +37,9 @@ export const AuthForm = (): ReactElement => {
-

Altricade

+

Zovi

{mode === 'login' ? 'Welcome back.' : 'Create your account.'}

diff --git a/packages/web/src/features/contacts/index.ts b/packages/web/src/features/contacts/index.ts index f900a0a..50a4cda 100644 --- a/packages/web/src/features/contacts/index.ts +++ b/packages/web/src/features/contacts/index.ts @@ -1,3 +1,4 @@ export { useContacts } from './model'; export type { UseContacts } from './model'; export { ContactsPanel } from './ui/ContactsPanel'; +export { UserPicker } from './ui/UserPicker'; diff --git a/packages/web/src/features/contacts/ui/ContactsPanel.tsx b/packages/web/src/features/contacts/ui/ContactsPanel.tsx index cfe7f7a..64c9ce3 100644 --- a/packages/web/src/features/contacts/ui/ContactsPanel.tsx +++ b/packages/web/src/features/contacts/ui/ContactsPanel.tsx @@ -1,52 +1,187 @@ -import { useState } from 'react'; -import type { ReactElement, SyntheticEvent } from 'react'; -import { ApiError } from '@altricade/core/api'; -import { PlusIcon, TrashIcon } from '../../../shared/ui'; +import { useEffect, useMemo, useState } from 'react'; +import type { ReactElement } from 'react'; +import type { Presence, PublicUser } from '@altricade/core'; +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'; +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 { onStartDirect: (username: string) => Promise; } +// 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 => { const { contacts, add, remove } = useContacts(); - const [name, setName] = useState(''); + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); const [error, setError] = useState(null); + const [presenceMap, setPresenceMap] = useState>(new Map()); - const submit = async (event: SyntheticEvent): Promise => { - event.preventDefault(); - const trimmed = name.trim(); - if (trimmed === '') { - return; + const contactIds = useMemo( + () => new Set(contacts.map((contact) => contact.userId)), + [contacts], + ); + + // 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 => { setError(null); try { - await add(trimmed); - setName(''); + await add(user.username); } catch (caught) { setError(caught instanceof ApiError ? caught.message : 'Could not add contact'); } }; + const searching = query.trim().length > 0; + return (
-

Contacts

-
void submit(event)}> +
+ { - setName(event.target.value); + setQuery(event.target.value); + setError(null); }} /> - - + {searching ? ( + + ) : null} +
{error !== null ?

{error}

: null} - {contacts.length === 0 ? ( -

No contacts yet

+ + {searching ? ( +
+

Matches

+ {results.length === 0 ? ( +

No matches

+ ) : ( + results.map((user) => { + const isContact = contactIds.has(user.id); + return ( +
+ + {isContact ? ( + + + + ) : ( + + )} +
+ ); + }) + )} +
+ ) : contacts.length === 0 ? ( +

No contacts yet — search to add people

) : (
    {contacts.map((contact) => ( @@ -67,7 +202,15 @@ export const ContactsPanel = ({ onStartDirect }: Props): ReactElement => { )} {contact.user.displayName} - @{contact.user.username} + + {lastSeenLabel(presenceMap.get(contact.userId)) || `@${contact.user.username}`} + + ); + }; + + return ( +
    + {selected.length > 0 ? ( +
    + {selected.map((user) => ( + + ))} +
    + ) : null} + +
    + + { + setQuery(event.target.value); + }} + /> +
    + +
    + {matchingContacts.length > 0 ? ( + <> +

    Contacts

    + {matchingContacts.map((user) => + row(user, lastSeenLabel(presenceMap.get(user.id)) || `@${user.username}`), + )} + + ) : null} + {globalResults.length > 0 ? ( + <> +

    Global search

    + {globalResults.map((user) => row(user, `@${user.username}`))} + + ) : null} + {matchingContacts.length === 0 && globalResults.length === 0 ? ( +

    {trimmed === '' ? 'No contacts yet' : 'No matches'}

    + ) : null} +
    +
    + ); +}; diff --git a/packages/web/src/features/conversations/index.ts b/packages/web/src/features/conversations/index.ts index c5f274c..70fce2a 100644 --- a/packages/web/src/features/conversations/index.ts +++ b/packages/web/src/features/conversations/index.ts @@ -1,3 +1,6 @@ export { useConversations } from './model'; export type { UseConversations } from './model'; export { ConversationSidebar } from './ui/ConversationSidebar'; +export { NewGroupPanel } from './ui/NewGroupPanel'; +export { NewChannelPanel } from './ui/NewChannelPanel'; +export { ForwardPicker } from './ui/ForwardPicker'; diff --git a/packages/web/src/features/conversations/model.ts b/packages/web/src/features/conversations/model.ts index bd48505..1dcf7be 100644 --- a/packages/web/src/features/conversations/model.ts +++ b/packages/web/src/features/conversations/model.ts @@ -5,6 +5,7 @@ import { listConversations, createDirect, createGroup, + createChannel, clearConversation, hideConversation, } from '@altricade/core/api'; @@ -17,6 +18,11 @@ export interface UseConversations { onlineMap: Record; startDirect: (username: string) => Promise; createGroupChat: (title: string, members: string[]) => Promise; + createChannelChat: ( + title: string, + description: string | null, + members: string[], + ) => Promise; /** Clear history for me — the chat stays listed with an empty scrollback. */ clearChat: (conversationId: string) => Promise; /** 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 => { + const conversation = await createChannel(apiConfig, { + title, + ...(description !== null ? { description } : {}), + members, + }); + setConversations((prev) => upsert(prev, conversation)); + return conversation; + }, + [], + ); + const clearChat = useCallback(async (conversationId: string): Promise => { await clearConversation(apiConfig, conversationId); setConversations((prev) => @@ -225,5 +244,14 @@ export const useConversations = ( await hideConversation(apiConfig, conversationId); }, []); - return { conversations, loading, onlineMap, startDirect, createGroupChat, clearChat, deleteChat }; + return { + conversations, + loading, + onlineMap, + startDirect, + createGroupChat, + createChannelChat, + clearChat, + deleteChat, + }; }; diff --git a/packages/web/src/features/conversations/ui/AvatarPicker.tsx b/packages/web/src/features/conversations/ui/AvatarPicker.tsx new file mode 100644 index 0000000..b2984fc --- /dev/null +++ b/packages/web/src/features/conversations/ui/AvatarPicker.tsx @@ -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(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): void => { + const input = event.currentTarget; + const picked = input.files?.[0]; + input.value = ''; + if (picked !== undefined) { + onPick(picked); + } + }; + + return ( + + ); +}; diff --git a/packages/web/src/features/conversations/ui/ConversationSidebar.tsx b/packages/web/src/features/conversations/ui/ConversationSidebar.tsx index effa64d..f7030ee 100644 --- a/packages/web/src/features/conversations/ui/ConversationSidebar.tsx +++ b/packages/web/src/features/conversations/ui/ConversationSidebar.tsx @@ -1,11 +1,13 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import type { ReactElement, SyntheticEvent } from 'react'; import type { ChatFolder, Conversation, PublicUser } from '@altricade/core'; import { searchUsers } from '@altricade/core/api'; import { apiConfig } from '../../../shared/api'; +import { useDebouncedValue } from '../../../shared/lib'; import { SearchIcon, UsersIcon, + MegaphoneIcon, CloseIcon, PlusIcon, PinIcon, @@ -44,13 +46,16 @@ type SidebarDialog = | { kind: 'delete-chat'; conversation: Conversation }; const title = (conversation: Conversation): string => { - if (conversation.type === 'group') { - return conversation.title ?? 'Group'; + if (conversation.type !== 'direct') { + return conversation.title ?? (conversation.type === 'channel' ? 'Channel' : 'Group'); } return conversation.peer === null ? 'Direct' : conversation.peer.displayName; }; const subtitle = (conversation: Conversation): string => { + if (conversation.type === 'channel') { + return 'Channel'; + } if (conversation.type === 'group') { return 'Group'; } @@ -81,13 +86,18 @@ const formatTime = (iso: string): string => { }; const Avatar = ({ conversation }: { conversation: Conversation }): ReactElement => { - const peer = conversation.peer; - if (peer !== null && peer.avatarUrl !== null) { - return ; + if (conversation.avatarUrl !== null) { + return ; } return ( - {conversation.type === 'group' ? : initial(conversation)} + {conversation.type === 'channel' ? ( + + ) : conversation.type === 'group' ? ( + + ) : ( + initial(conversation) + )} ); }; @@ -173,19 +183,30 @@ export const ConversationSidebar = ({ const [dialog, setDialog] = useState(null); const chatMenu = useContextMenu(); const folderMenu = useContextMenu(); + const debouncedQuery = useDebouncedValue(query.trim(), 300); - const runSearch = async (value: string): Promise => { - setQuery(value); - if (value.trim().length === 0) { + // Debounced people search — one request per quiet period, latest wins. + useEffect(() => { + if (debouncedQuery.length === 0) { setResults([]); - return; + return undefined; } - try { - setResults(await searchUsers(apiConfig, value.trim())); - } catch { - setResults([]); - } - }; + let cancelled = false; + void searchUsers(apiConfig, debouncedQuery) + .then((found) => { + if (!cancelled) { + setResults(found); + } + }) + .catch(() => { + if (!cancelled) { + setResults([]); + } + }); + return () => { + cancelled = true; + }; + }, [debouncedQuery]); const start = async (username: string): Promise => { await onStartDirect(username); @@ -225,7 +246,7 @@ export const ConversationSidebar = ({ placeholder="Search people…" value={query} onChange={(event) => { - void runSearch(event.target.value); + setQuery(event.target.value); }} /> {searching ? ( diff --git a/packages/web/src/features/conversations/ui/ForwardPicker.tsx b/packages/web/src/features/conversations/ui/ForwardPicker.tsx new file mode 100644 index 0000000..297b5ff --- /dev/null +++ b/packages/web/src/features/conversations/ui/ForwardPicker.tsx @@ -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 ( +
    +
    { + event.stopPropagation(); + }} + > +
    + + Forward {count > 1 ? `${String(count)} messages` : 'message'} + + +
    + +
    + + { + setQuery(event.target.value); + }} + /> +
    + + + +
    + {results.length === 0 ? ( +

    No chats

    + ) : ( + results.map((conversation) => ( + + )) + )} +
    +
    +
    + ); +}; diff --git a/packages/web/src/features/conversations/ui/NewChannelPanel.tsx b/packages/web/src/features/conversations/ui/NewChannelPanel.tsx new file mode 100644 index 0000000..cdb5620 --- /dev/null +++ b/packages/web/src/features/conversations/ui/NewChannelPanel.tsx @@ -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; +} + +// 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(null); + const [selected, setSelected] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(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 => { + 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 ( +
    +
    +
    + } /> + +
    +