diff --git a/.env.example b/.env.example index 935cac0..1994b7f 100644 --- a/.env.example +++ b/.env.example @@ -54,9 +54,12 @@ MINIO_PORT=9000 MINIO_USE_SSL=false MINIO_BUCKET_MEDIA=media MINIO_BUCKET_AVATARS=avatars -# Browser-facing MinIO URL — presigned upload/download URLs are signed for this -# host, so it must match what the browser uses (dev: the exposed host port). -MINIO_PUBLIC_URL=http://localhost:9000 +# Fallback public MinIO base URL. nginx proxies /media/ and /avatars/ at the +# gateway, and presigned URLs are normally signed for whatever origin the +# request arrived on (localhost for a browser, your LAN IP for a phone) — this +# value is used for persisted avatar URLs and as the presign fallback, so point +# it at the gateway origin phones can reach (e.g. http://:8080). +MINIO_PUBLIC_URL=http://localhost:8080 # S3 region used for SigV4 presigning (MinIO default is us-east-1). Set explicitly # so presigning never makes a network region-lookup call. MINIO_REGION=us-east-1 diff --git a/infra/nginx/nginx.conf b/infra/nginx/nginx.conf index ccd0f20..0bccffe 100644 --- a/infra/nginx/nginx.conf +++ b/infra/nginx/nginx.conf @@ -12,6 +12,9 @@ http { upstream centrifugo { server centrifugo:8000; } + upstream minio { + server minio:9000; + } server { listen 80; @@ -20,14 +23,33 @@ http { absolute_redirect off; # REST API — strip the /api prefix before proxying to the backend. + # $http_host (not $host) keeps the port: the backend presigns media URLs + # for the exact origin the client used to reach this gateway. location /api/ { proxy_pass http://backend/; - proxy_set_header Host $host; + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } + # Object storage through the same origin as the API, so presigned URLs work + # from any client that can reach this gateway (browsers AND phones). Paths + # match the bucket names (media, avatars). Host must be preserved verbatim — + # S3 v4 signatures cover it. + location /media/ { + proxy_pass http://minio; + proxy_set_header Host $http_host; + proxy_buffering off; + client_max_body_size 200m; + } + location /avatars/ { + proxy_pass http://minio; + proxy_set_header Host $http_host; + proxy_buffering off; + client_max_body_size 25m; + } + # Swagger UI + OpenAPI spec — served by the backend at /docs (HTML, static # assets and /docs/json all live under this prefix; pass through unmodified). # Redirect the slashless form so the UI's relative asset paths resolve. diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index d33ae0a..e0238df 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -99,7 +99,7 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise mediaService.downloadUrl(objectKey), + mediaDownloadUrl: (objectKey, origin) => mediaService.downloadUrl(objectKey, origin), notify: (message) => { void notificationQueue .enqueueMessage({ diff --git a/packages/backend/src/modules/auth/auth.routes.ts b/packages/backend/src/modules/auth/auth.routes.ts index 294792b..bca6e44 100644 --- a/packages/backend/src/modules/auth/auth.routes.ts +++ b/packages/backend/src/modules/auth/auth.routes.ts @@ -12,18 +12,40 @@ import type { IssuedAuth, RequestContext } from './auth.service'; const REFRESH_COOKIE = 'refresh_token'; +// Native clients (React Native) can't rely on cookies, so they send +// `X-Auth-Mode: token`; the backend then returns the refresh token in the body +// and reads it from the body instead of the cookie. +const isTokenMode = (request: FastifyRequest): boolean => + request.headers['x-auth-mode'] === 'token'; + const context = (request: FastifyRequest): RequestContext => ({ userAgent: request.headers['user-agent'] ?? null, ip: request.ip, }); -// Public response body — deliberately omits the refresh token (cookie only). -const publicResult = (issued: IssuedAuth): AuthResult => ({ +// Body shape carrying the refresh token in token mode (register/login omit it). +const refreshTokenFromBody = (body: unknown): string | undefined => { + if (typeof body === 'object' && body !== null && 'refreshToken' in body) { + const value: unknown = body.refreshToken; + return typeof value === 'string' ? value : undefined; + } + return undefined; +}; + +// Public response body — cookie mode omits the refresh token; token mode includes it. +const authResult = (issued: IssuedAuth, tokenMode: boolean): AuthResult => ({ user: issued.user, accessToken: issued.accessToken, accessTokenExpiresIn: issued.accessTokenExpiresIn, + ...(tokenMode ? { refreshToken: issued.refreshToken } : {}), }); +const refreshBodySchema = { + type: 'object', + additionalProperties: false, + properties: { refreshToken: { type: 'string' } }, +} as const; + // Throttle credential endpoints to blunt stuffing / enumeration. const authRateLimit = { rateLimit: { max: 10, timeWindow: '1 minute' } }; const bearerAuth = [{ bearerAuth: [] }]; @@ -58,8 +80,11 @@ export const authRoutes = (app: FastifyInstance): Promise => { }, async (request, reply) => { const issued = await app.authService.register(request.body, context(request)); - setRefreshCookie(reply, issued.refreshToken); - return reply.code(201).send(publicResult(issued)); + const tokenMode = isTokenMode(request); + if (!tokenMode) { + setRefreshCookie(reply, issued.refreshToken); + } + return reply.code(201).send(authResult(issued, tokenMode)); }, ); @@ -76,37 +101,54 @@ export const authRoutes = (app: FastifyInstance): Promise => { }, async (request, reply) => { const issued = await app.authService.login(request.body, context(request)); - setRefreshCookie(reply, issued.refreshToken); - return reply.send(publicResult(issued)); + const tokenMode = isTokenMode(request); + if (!tokenMode) { + setRefreshCookie(reply, issued.refreshToken); + } + return reply.send(authResult(issued, tokenMode)); }, ); - app.post( + app.post<{ Body?: { refreshToken?: string } }>( '/refresh', { schema: { tags: ['auth'], - summary: 'Rotate tokens using the refresh cookie (reuse detection)', + summary: 'Rotate tokens using the refresh cookie or body token (reuse detection)', + body: refreshBodySchema, response: { 200: authResultSchema, 401: errorSchema }, }, config: authRateLimit, }, async (request, reply) => { - const token = request.cookies[REFRESH_COOKIE]; + const tokenMode = isTokenMode(request); + const token = tokenMode + ? refreshTokenFromBody(request.body) + : request.cookies[REFRESH_COOKIE]; if (token === undefined) { return reply.code(401).send({ error: 'invalid_token', message: 'Missing refresh token' }); } const issued = await app.authService.refresh(token, context(request)); - setRefreshCookie(reply, issued.refreshToken); - return reply.send(publicResult(issued)); + if (!tokenMode) { + setRefreshCookie(reply, issued.refreshToken); + } + return reply.send(authResult(issued, tokenMode)); }, ); - app.post( + app.post<{ Body?: { refreshToken?: string } }>( '/logout', - { schema: { tags: ['auth'], summary: 'Revoke the current refresh token (this device)' } }, + { + schema: { + tags: ['auth'], + summary: 'Revoke the current refresh token (this device)', + body: refreshBodySchema, + }, + }, async (request, reply) => { - const token = request.cookies[REFRESH_COOKIE]; + const token = isTokenMode(request) + ? refreshTokenFromBody(request.body) + : request.cookies[REFRESH_COOKIE]; if (token !== undefined) { await app.authService.logout(token); } diff --git a/packages/backend/src/modules/conversations/conversations.mapper.ts b/packages/backend/src/modules/conversations/conversations.mapper.ts index 1e00b7f..f02ae16 100644 --- a/packages/backend/src/modules/conversations/conversations.mapper.ts +++ b/packages/backend/src/modules/conversations/conversations.mapper.ts @@ -1,5 +1,15 @@ -import type { Conversation, ConversationMember, PublicUser } from '@altricade/core'; -import type { ConversationRow, MemberWithUser } from './conversations.repository'; +import type { + Conversation, + ConversationMember, + LastMessagePreview, + MediaKind, + PublicUser, +} from '@altricade/core'; +import type { + ConversationRow, + ConversationListRow, + MemberWithUser, +} from './conversations.repository'; export const memberToPublicUser = (member: MemberWithUser): PublicUser => ({ id: member.user_id, @@ -8,10 +18,30 @@ export const memberToPublicUser = (member: MemberWithUser): PublicUser => ({ avatarUrl: member.avatar_ref, }); +const MEDIA_KINDS: readonly MediaKind[] = ['image', 'video', 'video_note', 'voice', 'file']; + +const toMediaKind = (value: string | null): MediaKind | null => + MEDIA_KINDS.find((kind) => kind === value) ?? null; + +export const toLastMessagePreview = (row: ConversationListRow): LastMessagePreview | null => { + if (row.last_msg_sender_id === null) { + return null; + } + const deleted = row.last_msg_deleted_at !== null; + return { + senderId: row.last_msg_sender_id, + senderName: row.last_msg_sender_name ?? '', + content: deleted ? '' : (row.last_msg_content ?? ''), + mediaKind: deleted ? null : toMediaKind(row.last_msg_media_kind), + deleted, + }; +}; + export const toConversation = ( row: ConversationRow, peer: PublicUser | null, unreadCount = 0, + lastMessage: LastMessagePreview | null = null, ): Conversation => ({ id: row.id, type: row.type === 'direct' ? 'direct' : row.type === 'channel' ? 'channel' : 'group', @@ -23,6 +53,7 @@ export const toConversation = ( createdBy: row.created_by, createdAt: row.created_at.toISOString(), lastMessageAt: row.last_message_at.toISOString(), + lastMessage, unreadCount, }); diff --git a/packages/backend/src/modules/conversations/conversations.repository.ts b/packages/backend/src/modules/conversations/conversations.repository.ts index dc5958c..70c4ccf 100644 --- a/packages/backend/src/modules/conversations/conversations.repository.ts +++ b/packages/backend/src/modules/conversations/conversations.repository.ts @@ -4,6 +4,16 @@ import type { Database, ConversationsTable } from '../../db/schema'; export type ConversationRow = Selectable; +// List rows carry the newest message visible to the requesting user (chat-list +// preview); all fields are null for an empty (or fully cleared) conversation. +export interface ConversationListRow extends ConversationRow { + last_msg_sender_id: string | null; + last_msg_sender_name: string | null; + last_msg_content: string | null; + last_msg_media_kind: string | null; + last_msg_deleted_at: Date | null; +} + export interface MemberWithUser { user_id: string; role: string; @@ -35,7 +45,7 @@ export interface ConversationsRepository { ): Promise; findOrCreateDirect(userA: string, userB: string): Promise; findById(id: string): Promise; - listForUser(userId: string): Promise; + listForUser(userId: string): Promise; peersForDirect(userId: string, conversationIds: string[]): Promise>; getPeer(conversationId: string, userId: string): Promise; isMember(conversationId: string, userId: string): Promise; @@ -206,7 +216,49 @@ export const createConversationsRepository = (db: Kysely): Conversatio eb('conversations.last_message_at', '>', eb.ref('conversation_members.hidden_at')), ]), ) + // Chat-list preview: the newest message this user can still see — + // the same visibility rules as history (above the clear marker, not + // "deleted for me"). + .leftJoinLateral( + (eb) => + eb + .selectFrom('messages') + .innerJoin('users', 'users.id', 'messages.sender_id') + .whereRef('messages.conversation_id', '=', 'conversations.id') + .whereRef('messages.seq', '>', 'conversation_members.cleared_up_to_seq') + .where((web) => + web.not( + web.exists( + web + .selectFrom('message_hidden') + .select('message_hidden.message_id') + .whereRef('message_hidden.message_id', '=', 'messages.id') + .where('message_hidden.user_id', '=', userId), + ), + ), + ) + .select((web) => [ + 'messages.sender_id as last_msg_sender_id', + 'users.display_name as last_msg_sender_name', + 'messages.content as last_msg_content', + web + .fn('nullif', [sql`messages.media_meta->>'kind'`, sql`''`]) + .as('last_msg_media_kind'), + 'messages.deleted_at as last_msg_deleted_at', + ]) + .orderBy('messages.seq', 'desc') + .limit(1) + .as('last_msg'), + (join) => join.onTrue(), + ) .selectAll('conversations') + .select([ + 'last_msg.last_msg_sender_id', + 'last_msg.last_msg_sender_name', + 'last_msg.last_msg_content', + 'last_msg.last_msg_media_kind', + 'last_msg.last_msg_deleted_at', + ]) .orderBy('conversations.last_message_at', 'desc') .execute(), diff --git a/packages/backend/src/modules/conversations/conversations.service.ts b/packages/backend/src/modules/conversations/conversations.service.ts index 399466c..1efeb55 100644 --- a/packages/backend/src/modules/conversations/conversations.service.ts +++ b/packages/backend/src/modules/conversations/conversations.service.ts @@ -15,7 +15,12 @@ import type { UsersRepository } from '../users'; import type { ConversationsRepository, DeliveryInfo } from './conversations.repository'; import type { ReadStateRepository } from './read-state.repository'; import type { Deliver } from './delivery'; -import { toConversation, toMember, memberToPublicUser } from './conversations.mapper'; +import { + toConversation, + toLastMessagePreview, + toMember, + memberToPublicUser, +} from './conversations.mapper'; export interface ConversationsServiceDeps { conversations: ConversationsRepository; @@ -145,11 +150,17 @@ export const createConversationsService = ( const unread = await readState.unreadCounts(userId); return rows.map((row) => { const count = unread.get(row.id) ?? 0; + const lastMessage = toLastMessagePreview(row); if (row.type !== 'direct') { - return toConversation(row, null, count); + return toConversation(row, null, count, lastMessage); } const peer = peers.get(row.id); - return toConversation(row, peer === undefined ? null : memberToPublicUser(peer), count); + return toConversation( + row, + peer === undefined ? null : memberToPublicUser(peer), + count, + lastMessage, + ); }); }, diff --git a/packages/backend/src/modules/media/media.routes.ts b/packages/backend/src/modules/media/media.routes.ts index 29dc6cc..0e4559e 100644 --- a/packages/backend/src/modules/media/media.routes.ts +++ b/packages/backend/src/modules/media/media.routes.ts @@ -7,6 +7,7 @@ import { errorSchema, } from '@altricade/core'; import type { UploadUrlBody, AvatarUploadBody } from '@altricade/core'; +import { requestOrigin } from '../../shared/request-origin'; const bearerAuth = [{ bearerAuth: [] }]; @@ -31,6 +32,7 @@ export const mediaRoutes = (app: FastifyInstance): Promise => { request.body.kind, request.body.mime, request.body.size, + requestOrigin(request), ); return reply.send(target); }, @@ -51,7 +53,11 @@ export const mediaRoutes = (app: FastifyInstance): Promise => { async (request, reply) => { const user = request.authUser; if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); - const target = await app.mediaService.createAvatarUploadUrl(user.id, request.body.mime); + const target = await app.mediaService.createAvatarUploadUrl( + user.id, + request.body.mime, + requestOrigin(request), + ); return reply.send(target); }, ); diff --git a/packages/backend/src/modules/media/media.service.ts b/packages/backend/src/modules/media/media.service.ts index 45eae7a..8682332 100644 --- a/packages/backend/src/modules/media/media.service.ts +++ b/packages/backend/src/modules/media/media.service.ts @@ -4,8 +4,8 @@ import type { MediaKind } from '@altricade/core'; import { HttpError } from '../../shared/http-error'; export interface MediaServiceDeps { - // The presigning (browser-facing) MinIO client. - minio: Client; + // Presigning client for a given public origin (null → configured public URL). + presignClient: (origin: string | null) => Client; mediaBucket: string; avatarsBucket: string; publicUrl: string; @@ -20,10 +20,19 @@ export interface AvatarTarget extends UploadTarget { publicUrl: string; } +// `origin` is the public origin the request arrived on — presigned URLs are +// signed for it so they stay reachable from that same client (a browser on +// localhost and a phone on a LAN IP get different, individually valid URLs). export interface MediaService { - createUploadUrl(userId: string, kind: MediaKind, mime: string, size: number): Promise; - createAvatarUploadUrl(userId: string, mime: string): Promise; - downloadUrl(objectKey: string): Promise; + createUploadUrl( + userId: string, + kind: MediaKind, + mime: string, + size: number, + origin: string | null, + ): Promise; + createAvatarUploadUrl(userId: string, mime: string, origin: string | null): Promise; + downloadUrl(objectKey: string, origin: string | null): Promise; avatarPublicUrl(objectKey: string): string; } @@ -38,25 +47,25 @@ const kindMatches = (kind: MediaKind, mime: string): boolean => { }; export const createMediaService = (deps: MediaServiceDeps): MediaService => ({ - createUploadUrl: async (userId, kind, mime, _size) => { + createUploadUrl: async (userId, kind, mime, _size, origin) => { if (!kindMatches(kind, mime)) { throw new HttpError(400, 'invalid_media', `Content type ${mime} does not match kind ${kind}`); } const objectKey = `${userId}/${randomUUID()}`; - const uploadUrl = await deps.minio.presignedPutObject(deps.mediaBucket, objectKey, UPLOAD_EXPIRY); + const uploadUrl = await deps + .presignClient(origin) + .presignedPutObject(deps.mediaBucket, objectKey, UPLOAD_EXPIRY); return { uploadUrl, objectKey }; }, - createAvatarUploadUrl: async (userId, mime) => { + createAvatarUploadUrl: async (userId, mime, origin) => { if (!mime.startsWith('image/')) { throw new HttpError(400, 'invalid_media', 'Avatar must be an image'); } const objectKey = `${userId}/${randomUUID()}`; - const uploadUrl = await deps.minio.presignedPutObject( - deps.avatarsBucket, - objectKey, - UPLOAD_EXPIRY, - ); + const uploadUrl = await deps + .presignClient(origin) + .presignedPutObject(deps.avatarsBucket, objectKey, UPLOAD_EXPIRY); return { uploadUrl, objectKey, @@ -64,9 +73,11 @@ export const createMediaService = (deps: MediaServiceDeps): MediaService => ({ }; }, - downloadUrl: (objectKey) => - deps.minio.presignedGetObject(deps.mediaBucket, objectKey, DOWNLOAD_EXPIRY), + downloadUrl: (objectKey, origin) => + deps.presignClient(origin).presignedGetObject(deps.mediaBucket, objectKey, DOWNLOAD_EXPIRY), + // Avatars are persisted (users.avatar_ref), so they use the one stable + // configured public URL rather than a per-request origin. avatarPublicUrl: (objectKey) => `${deps.publicUrl}/${deps.avatarsBucket}/${objectKey}`, }); diff --git a/packages/backend/src/modules/messages/messages.routes.ts b/packages/backend/src/modules/messages/messages.routes.ts index 6d97ce1..c29eeb0 100644 --- a/packages/backend/src/modules/messages/messages.routes.ts +++ b/packages/backend/src/modules/messages/messages.routes.ts @@ -17,6 +17,7 @@ import type { ForwardMessageBody, MediaTab, } from '@altricade/core'; +import { requestOrigin } from '../../shared/request-origin'; const bearerAuth = [{ bearerAuth: [] }]; const idParamsSchema = { @@ -364,7 +365,12 @@ export const messagesRoutes = (app: FastifyInstance): Promise => { async (request, reply) => { const user = request.authUser; if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); - const url = await app.messagesService.mediaUrl(request.params.id, request.params.messageId, user.id); + const url = await app.messagesService.mediaUrl( + request.params.id, + request.params.messageId, + user.id, + requestOrigin(request), + ); return reply.send({ url }); }, ); diff --git a/packages/backend/src/modules/messages/messages.service.ts b/packages/backend/src/modules/messages/messages.service.ts index 1283cd5..7dfdf9d 100644 --- a/packages/backend/src/modules/messages/messages.service.ts +++ b/packages/backend/src/modules/messages/messages.service.ts @@ -25,7 +25,7 @@ export interface MessagesServiceDeps { deliver: Deliver; /** Personal-channel publisher for per-user view-state events. */ publish: Publisher; - mediaDownloadUrl: (objectKey: string) => Promise; + mediaDownloadUrl: (objectKey: string, origin: string | null) => Promise; /** Fire-and-forget push-notification hook, called for each newly-created message. */ notify: (message: Message) => void; } @@ -101,7 +101,12 @@ export interface MessagesService { userId: string, emoji: string, ): Promise; - mediaUrl(conversationId: string, messageId: string, userId: string): Promise; + mediaUrl( + conversationId: string, + messageId: string, + userId: string, + origin: string | null, + ): Promise; } export const createMessagesService = (deps: MessagesServiceDeps): MessagesService => { @@ -405,7 +410,7 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic await deliver(conversationId, event); }, - mediaUrl: async (conversationId, messageId, userId) => { + mediaUrl: async (conversationId, messageId, userId, origin) => { await assertMember(conversationId, userId); await assertMessageIn(conversationId, messageId); const row = await messages.getWithSenderById(messageId); @@ -413,7 +418,7 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic if (mediaKey === null) { throw new HttpError(404, 'not_found', 'No media on this message'); } - return mediaDownloadUrl(mediaKey); + return mediaDownloadUrl(mediaKey, origin); }, }; }; diff --git a/packages/backend/src/plugins/minio.ts b/packages/backend/src/plugins/minio.ts index 19351b8..6b147b9 100644 --- a/packages/backend/src/plugins/minio.ts +++ b/packages/backend/src/plugins/minio.ts @@ -4,9 +4,11 @@ import { Client } from 'minio'; declare module 'fastify' { interface FastifyInstance { minio: Client; - // Client configured with the browser-facing host — used ONLY for presigning - // upload/download URLs so the signature matches the host the browser hits. - minioPublic: Client; + // Returns a client configured for the given public origin (e.g. the host a + // browser or phone reached the gateway on) — used ONLY for presigning, so + // the S3 signature matches the host the device will actually hit. Falls + // back to the configured MINIO_PUBLIC_URL when origin is null. + minioPresign: (origin: string | null) => Client; } } @@ -18,20 +20,30 @@ export const minioPlugin = fp( const client = new Client({ endPoint: endpoint, port, useSSL, accessKey, secretKey, region }); app.decorate('minio', client); - const parsed = new URL(publicUrl); - const publicSecure = parsed.protocol === 'https:'; - const publicPort = parsed.port === '' ? (publicSecure ? 443 : 80) : Number.parseInt(parsed.port, 10); - // Explicit region so presigning is purely computational — no getBucketRegion - // network call to the browser-facing host (unreachable from inside the container). - const publicClient = new Client({ - endPoint: parsed.hostname, - port: publicPort, - useSSL: publicSecure, - accessKey, - secretKey, - region, - }); - app.decorate('minioPublic', publicClient); + // One presigning client per public origin, cached — presigning is purely + // computational (explicit region → no getBucketRegion network call to a + // host that's unreachable from inside the container). + const presignClients = new Map(); + const clientFor = (base: string): Client => { + const cached = presignClients.get(base); + if (cached !== undefined) { + return cached; + } + const parsed = new URL(base); + const secure = parsed.protocol === 'https:'; + const parsedPort = parsed.port === '' ? (secure ? 443 : 80) : Number.parseInt(parsed.port, 10); + const created = new Client({ + endPoint: parsed.hostname, + port: parsedPort, + useSSL: secure, + accessKey, + secretKey, + region, + }); + presignClients.set(base, created); + return created; + }; + app.decorate('minioPresign', (origin: string | null) => clientFor(origin ?? publicUrl)); return Promise.resolve(); }, diff --git a/packages/backend/src/shared/request-origin.ts b/packages/backend/src/shared/request-origin.ts new file mode 100644 index 0000000..07a2ab4 --- /dev/null +++ b/packages/backend/src/shared/request-origin.ts @@ -0,0 +1,15 @@ +import type { FastifyRequest } from 'fastify'; + +// The public origin the client used to reach the gateway (nginx forwards the +// original Host verbatim and stamps X-Forwarded-Proto). Presigning media URLs +// for THIS origin is what lets one backend serve browsers on localhost and +// phones on a LAN IP simultaneously. Null when the request didn't come through +// the proxy (direct dev access) — callers fall back to the configured URL. +export const requestOrigin = (request: FastifyRequest): string | null => { + const proto = request.headers['x-forwarded-proto']; + const host = request.headers.host; + if (typeof proto === 'string' && proto.length > 0 && typeof host === 'string' && host.length > 0) { + return `${proto.split(',')[0] ?? proto}://${host}`; + } + return null; +}; diff --git a/packages/core/src/api/auth.ts b/packages/core/src/api/auth.ts index 18baaff..39d651c 100644 --- a/packages/core/src/api/auth.ts +++ b/packages/core/src/api/auth.ts @@ -22,16 +22,24 @@ export const register = async (config: ApiClientConfig, body: RegisterBody): Pro export const login = async (config: ApiClientConfig, body: LoginBody): Promise => parse(authResultV, await requestJson(config, 'POST', '/auth/login', body)); -// Uses the httpOnly refresh cookie — no body. Single-flighted: refresh rotates -// the cookie, so two concurrent calls (double-mounted bootstrap effect, several -// features racing on a 401) would replay the same token and trip the server's -// reuse detection. All concurrent callers share one in-flight request. +// Cookie mode: uses the httpOnly refresh cookie (no body). Token mode: sends the +// stored refresh token in the body. Single-flighted: refresh rotates the token, +// so concurrent calls (double-mounted bootstrap, features racing on a 401) would +// replay it and trip the server's reuse detection — all callers share one request. let inflightRefresh: Promise | null = null; +const refreshBody = (config: ApiClientConfig): { refreshToken: string } | undefined => { + if (config.authMode !== 'token') { + return undefined; + } + const token = config.getRefreshToken?.() ?? null; + return token === null ? undefined : { refreshToken: token }; +}; + export const refresh = (config: ApiClientConfig): Promise => { inflightRefresh ??= (async () => { try { - return parse(authResultV, await requestJson(config, 'POST', '/auth/refresh')); + return parse(authResultV, await requestJson(config, 'POST', '/auth/refresh', refreshBody(config))); } finally { inflightRefresh = null; } @@ -40,7 +48,7 @@ export const refresh = (config: ApiClientConfig): Promise => { }; export const logout = async (config: ApiClientConfig): Promise => { - await requestJson(config, 'POST', '/auth/logout'); + await requestJson(config, 'POST', '/auth/logout', refreshBody(config)); }; export const logoutAll = async (config: ApiClientConfig): Promise => { diff --git a/packages/core/src/api/http.ts b/packages/core/src/api/http.ts index c87c424..fe602fb 100644 --- a/packages/core/src/api/http.ts +++ b/packages/core/src/api/http.ts @@ -25,6 +25,15 @@ export interface ApiClientConfig { baseUrl: string; /** Supplies the current access token for the Authorization header, if any. */ getAccessToken?: () => string | null; + /** + * Refresh-token transport. 'cookie' (default) uses the httpOnly refresh cookie + * (web). 'token' (native) sends `X-Auth-Mode: token`; the backend then returns + * the refresh token in the body and accepts it from the body — cookies are + * unreliable in React Native. + */ + authMode?: 'cookie' | 'token'; + /** Current stored refresh token (token mode only). */ + getRefreshToken?: () => string | null; } const toApiError = (status: number, json: unknown): ApiError => { @@ -45,6 +54,7 @@ export const requestJson = async ( path: string, body?: unknown, ): Promise => { + const tokenMode = config.authMode === 'token'; const headers: Record = { accept: 'application/json' }; if (body !== undefined) { headers['content-type'] = 'application/json'; @@ -53,11 +63,16 @@ export const requestJson = async ( if (token !== null) { headers['authorization'] = `Bearer ${token}`; } + if (tokenMode) { + headers['x-auth-mode'] = 'token'; + } const response = await fetch(`${config.baseUrl}${path}`, { method, headers, - credentials: 'include', + // Cookie mode relies on the refresh cookie; token mode carries the refresh + // token explicitly, so no ambient credentials are needed. + credentials: tokenMode ? 'omit' : 'include', body: body === undefined ? null : JSON.stringify(body), }); diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index 151a42b..15a76eb 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -13,7 +13,7 @@ export { getCentrifugoToken, } from './auth'; export { sendEcho } from './realtime'; -export { searchUsers, setAvatar } from './users'; +export { searchUsers, getUserByUsername, setAvatar } from './users'; export { listConversations, getConversation, diff --git a/packages/core/src/api/users.ts b/packages/core/src/api/users.ts index 849cf65..f27c953 100644 --- a/packages/core/src/api/users.ts +++ b/packages/core/src/api/users.ts @@ -1,9 +1,10 @@ -import { publicUserListSchema, userSchema } from '../schemas/index'; +import { publicUserSchema, publicUserListSchema, userSchema } from '../schemas/index'; import type { SetAvatarBody } from '../schemas/index'; import type { PublicUser, User } from '../types/index'; import { compileValidator, parse, requestJson } from './http'; import type { ApiClientConfig } from './http'; +const publicUserV = compileValidator(publicUserSchema); const publicUserListV = compileValidator(publicUserListSchema); const userV = compileValidator(userSchema); @@ -16,5 +17,11 @@ export const searchUsers = async ( await requestJson(config, 'GET', `/users/search?q=${encodeURIComponent(query)}`), ); +export const getUserByUsername = async ( + config: ApiClientConfig, + username: string, +): Promise => + parse(publicUserV, await requestJson(config, 'GET', `/users/${encodeURIComponent(username)}`)); + export const setAvatar = async (config: ApiClientConfig, body: SetAvatarBody): Promise => parse(userV, await requestJson(config, 'POST', '/me/avatar', body)); diff --git a/packages/core/src/schemas/entities.ts b/packages/core/src/schemas/entities.ts index 69c55da..8fb578f 100644 --- a/packages/core/src/schemas/entities.ts +++ b/packages/core/src/schemas/entities.ts @@ -51,6 +51,9 @@ export const authResultSchema = { user: userSchema, accessToken: { type: 'string' }, accessTokenExpiresIn: { type: 'integer' }, + // Only present in token mode (mobile/native): the opaque refresh token, which + // the client stores in secure storage. Cookie mode (web) never returns it. + refreshToken: { type: 'string' }, }, } as const; @@ -107,6 +110,7 @@ export const conversationSchema = { 'createdBy', 'createdAt', 'lastMessageAt', + 'lastMessage', 'unreadCount', ], properties: { @@ -121,6 +125,23 @@ export const conversationSchema = { createdBy: { type: 'string', format: 'uuid' }, createdAt: { type: 'string', format: 'date-time' }, lastMessageAt: { type: 'string', format: 'date-time' }, + lastMessage: { + oneOf: [ + { + type: 'object', + additionalProperties: false, + required: ['senderId', 'senderName', 'content', 'mediaKind', 'deleted'], + properties: { + senderId: { type: 'string', format: 'uuid' }, + senderName: { type: 'string' }, + content: { type: 'string' }, + mediaKind: { type: ['string', 'null'] }, + deleted: { type: 'boolean' }, + }, + }, + { type: 'null' }, + ], + }, unreadCount: { type: 'integer' }, }, } as const; diff --git a/packages/core/src/types/auth.ts b/packages/core/src/types/auth.ts index 2aabadf..0a20add 100644 --- a/packages/core/src/types/auth.ts +++ b/packages/core/src/types/auth.ts @@ -7,6 +7,8 @@ export interface AuthResult { accessToken: string; /** Access-token lifetime in seconds. */ accessTokenExpiresIn: number; + /** Present only in token mode (native clients); stored in secure storage. */ + refreshToken?: string; } // An active login session (one per device), from GET /auth/sessions. diff --git a/packages/core/src/types/conversation.ts b/packages/core/src/types/conversation.ts index 9882185..3a28d70 100644 --- a/packages/core/src/types/conversation.ts +++ b/packages/core/src/types/conversation.ts @@ -1,9 +1,21 @@ import type { PublicUser } from './user'; +import type { MediaKind } from '../schemas/media'; // 'channel' is a broadcast conversation: everyone reads, only the owner and // admins (conversation_members.role) may post or edit. export type ConversationType = 'direct' | 'group' | 'channel'; +/** Chat-list preview of the newest message visible to the requesting user. */ +export interface LastMessagePreview { + senderId: string; + senderName: string; + /** Text/caption; empty for a pure-media or deleted message. */ + content: string; + /** Media kind when the message carries media, else null. */ + mediaKind: MediaKind | null; + deleted: boolean; +} + export interface Conversation { id: string; type: ConversationType; @@ -18,6 +30,8 @@ export interface Conversation { createdBy: string; createdAt: string; lastMessageAt: string; + /** Newest visible message for the chat-list row; null for an empty chat. */ + lastMessage: LastMessagePreview | null; unreadCount: number; } diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index f25567d..e4c7132 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -3,7 +3,12 @@ export const CORE_VERSION = '0.1.0'; export type { User, PublicUser } from './user'; export type { AuthResult, Session, CentrifugoToken } from './auth'; -export type { Conversation, ConversationType, ConversationMember } from './conversation'; +export type { + Conversation, + ConversationType, + ConversationMember, + LastMessagePreview, +} from './conversation'; export type { Contact } from './contact'; export type { Message, ReactionSummary, ReplyPreview, ForwardOrigin, MediaRef } from './message'; export type { Presence } from './presence'; diff --git a/packages/mobile/.gitignore b/packages/mobile/.gitignore new file mode 100644 index 0000000..5873d9a --- /dev/null +++ b/packages/mobile/.gitignore @@ -0,0 +1,6 @@ + +# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb +# The following patterns were generated by expo-cli + +expo-env.d.ts +# @end expo-cli \ No newline at end of file diff --git a/packages/mobile/README.md b/packages/mobile/README.md new file mode 100644 index 0000000..9e71398 --- /dev/null +++ b/packages/mobile/README.md @@ -0,0 +1,67 @@ +# Zovi Mobile (Expo) + +React Native + Expo (SDK 54) client for Altricade, consuming the shared +`@altricade/core` package as source. FSD layout: `app/` holds thin expo-router +route files only; all logic and UI live under `src/`. + +## Prerequisites + +- The backend + infra stack running (Postgres, Redis, MinIO, Centrifugo) — see + `infra/docker-compose.yml`. Start the Fastify backend so the API is on + `http://localhost:8080`. +- On a physical device the API/WS host must be reachable from the phone, so + point the client at your machine's LAN IP (see env below), not `localhost`. + +## Run + +```bash +pnpm install # from the repo root + +# From packages/mobile: +pnpm start # Metro + dev menu +pnpm ios # build & run iOS (needs a dev build) +pnpm android # build & run Android (needs a dev build) +``` + +This app uses native modules (reanimated, gesture-handler, camera, audio, +video, secure-store, notifications), so it requires a **development build** +(`expo-dev-client`) — it will not run in Expo Go. Build one with EAS or +`expo run:ios` / `expo run:android`. + +### Environment + +Public config is read from `app.config.ts` `extra`, driven by `EXPO_PUBLIC_*`: + +| Variable | Default | Purpose | +| ------------------------- | -------------------------------------------------- | -------------------------------- | +| `EXPO_PUBLIC_API_URL` | `http://localhost:8080/api` | REST base URL | +| `EXPO_PUBLIC_WS_URL` | `ws://localhost:8080/connection/websocket` | Centrifugo websocket | +| `EXPO_PUBLIC_PUSH_ENABLED`| unset (off) | Enable native push registration | + +Example for a device on your LAN: + +```bash +EXPO_PUBLIC_API_URL=http://192.168.1.20:8080/api \ +EXPO_PUBLIC_WS_URL=ws://192.168.1.20:8080/connection/websocket \ +pnpm start +``` + +## Quality gates + +```bash +pnpm typecheck # tsc --noEmit (strict, no any/assertions/!) +pnpm lint # eslint +``` + +## Auth transport + +Native uses token-mode auth: the refresh token is stored in `expo-secure-store` +(Keychain/Keystore) and sent in the request body with an `X-Auth-Mode: token` +header, instead of the web's httpOnly cookie. The access token stays in memory. + +## Push notifications + +Native push (FCM/APNs) needs a build with push credentials and cannot run in +Expo Go, so registration is gated behind `EXPO_PUBLIC_PUSH_ENABLED`. Tap-to-open +deep-linking (`src/services/usePush.ts`) is always wired, so once credentials +are configured, flip the flag and notifications route into the right chat. diff --git a/packages/mobile/app.config.ts b/packages/mobile/app.config.ts new file mode 100644 index 0000000..be4812a --- /dev/null +++ b/packages/mobile/app.config.ts @@ -0,0 +1,46 @@ +import type { ExpoConfig } from 'expo/config'; + +// Public runtime config only — real secrets stay server-side. The API/WS base +// URLs point at the same gateway the web client uses; override per environment +// with EXPO_PUBLIC_API_URL / EXPO_PUBLIC_WS_URL (e.g. a LAN IP for a device). +const apiUrl = process.env['EXPO_PUBLIC_API_URL'] ?? 'http://localhost:8080/api'; +const wsUrl = process.env['EXPO_PUBLIC_WS_URL'] ?? 'ws://localhost:8080/connection/websocket'; + +const config: ExpoConfig = { + name: 'Zovi', + slug: 'zovi', + scheme: 'zovi', + version: '0.1.0', + orientation: 'portrait', + userInterfaceStyle: 'automatic', + newArchEnabled: true, + ios: { + supportsTablet: true, + bundleIdentifier: 'com.altricade.messenger', + }, + android: { + package: 'com.altricade.messenger', + edgeToEdgeEnabled: true, + }, + plugins: [ + 'expo-router', + 'expo-secure-store', + ['expo-audio', { microphonePermission: 'Zovi uses the microphone to record voice messages.' }], + 'expo-video', + ['expo-camera', { cameraPermission: 'Zovi uses the camera for video messages.' }], + [ + 'expo-image-picker', + { photosPermission: 'Zovi accesses your photos to share images and videos.' }, + ], + 'expo-notifications', + ], + experiments: { + typedRoutes: true, + }, + extra: { + apiUrl, + wsUrl, + }, +}; + +export default config; diff --git a/packages/mobile/app/(app)/(tabs)/_layout.tsx b/packages/mobile/app/(app)/(tabs)/_layout.tsx new file mode 100644 index 0000000..6e588dc --- /dev/null +++ b/packages/mobile/app/(app)/(tabs)/_layout.tsx @@ -0,0 +1,46 @@ +import type { ReactElement } from 'react'; +import { Tabs } from 'expo-router'; +import { Icon } from '@/components'; +import type { IconName } from '@/components'; +import { useTheme } from '@/theme'; + +// Open on Chats, not the leftmost (Contacts) tab. +export const unstable_settings = { + initialRouteName: 'index', +}; + +const tabIcon = + (name: IconName) => + ({ color, size }: { color: string; size: number }): ReactElement => ( + + ); + +// Persistent bottom navigation for the authenticated area: Contacts · Chats · +// Settings. Chat/profile/group and modals live in the parent stack so they +// cover the tab bar when opened. +export default function TabsLayout(): ReactElement { + const { colors } = useTheme(); + return ( + + + + + + ); +} diff --git a/packages/mobile/app/(app)/(tabs)/contacts.tsx b/packages/mobile/app/(app)/(tabs)/contacts.tsx new file mode 100644 index 0000000..dd36d54 --- /dev/null +++ b/packages/mobile/app/(app)/(tabs)/contacts.tsx @@ -0,0 +1,6 @@ +import type { ReactElement } from 'react'; +import { ContactsScreen } from '@/features/contacts'; + +export default function ContactsRoute(): ReactElement { + return ; +} diff --git a/packages/mobile/app/(app)/(tabs)/index.tsx b/packages/mobile/app/(app)/(tabs)/index.tsx new file mode 100644 index 0000000..c3257fd --- /dev/null +++ b/packages/mobile/app/(app)/(tabs)/index.tsx @@ -0,0 +1,6 @@ +import type { ReactElement } from 'react'; +import { ChatsScreen } from '@/features/conversations'; + +export default function ChatsRoute(): ReactElement { + return ; +} diff --git a/packages/mobile/app/(app)/(tabs)/settings.tsx b/packages/mobile/app/(app)/(tabs)/settings.tsx new file mode 100644 index 0000000..0cd636b --- /dev/null +++ b/packages/mobile/app/(app)/(tabs)/settings.tsx @@ -0,0 +1,6 @@ +import type { ReactElement } from 'react'; +import { SettingsScreen } from '@/features/settings'; + +export default function SettingsRoute(): ReactElement { + return ; +} diff --git a/packages/mobile/app/(app)/_layout.tsx b/packages/mobile/app/(app)/_layout.tsx new file mode 100644 index 0000000..3a07e78 --- /dev/null +++ b/packages/mobile/app/(app)/_layout.tsx @@ -0,0 +1,29 @@ +import type { ReactElement } from 'react'; +import { Stack } from 'expo-router'; +import { RealtimeProvider } from '@/ws'; +import { useTheme } from '@/theme'; +import { usePush } from '@/services/usePush'; + +// Authenticated area: everything here has a live realtime connection. +export default function AppLayout(): ReactElement { + const { colors } = useTheme(); + usePush(); + return ( + + + + + + + + + + + + ); +} diff --git a/packages/mobile/app/(app)/chat/[id].tsx b/packages/mobile/app/(app)/chat/[id].tsx new file mode 100644 index 0000000..eb16a54 --- /dev/null +++ b/packages/mobile/app/(app)/chat/[id].tsx @@ -0,0 +1,12 @@ +import type { ReactElement } from 'react'; +import { useLocalSearchParams } from 'expo-router'; +import { ChatScreen } from '@/features/messaging'; + +export default function ChatRoute(): ReactElement | null { + const params = useLocalSearchParams<{ id: string }>(); + const id = typeof params.id === 'string' ? params.id : null; + if (id === null) { + return null; + } + return ; +} diff --git a/packages/mobile/app/(app)/forward.tsx b/packages/mobile/app/(app)/forward.tsx new file mode 100644 index 0000000..cdb8f91 --- /dev/null +++ b/packages/mobile/app/(app)/forward.tsx @@ -0,0 +1,14 @@ +import type { ReactElement } from 'react'; +import { useLocalSearchParams } from 'expo-router'; +import { ForwardScreen } from '@/features/messaging'; + +export default function ForwardRoute(): ReactElement | null { + const params = useLocalSearchParams<{ ids: string; from: string }>(); + const idsParam = typeof params.ids === 'string' ? params.ids : ''; + const from = typeof params.from === 'string' ? params.from : null; + const messageIds = idsParam.split(',').filter((id) => id.length > 0); + if (from === null || messageIds.length === 0) { + return null; + } + return ; +} diff --git a/packages/mobile/app/(app)/group/[id].tsx b/packages/mobile/app/(app)/group/[id].tsx new file mode 100644 index 0000000..5055a4b --- /dev/null +++ b/packages/mobile/app/(app)/group/[id].tsx @@ -0,0 +1,12 @@ +import type { ReactElement } from 'react'; +import { useLocalSearchParams } from 'expo-router'; +import { GroupInfoScreen } from '@/features/profile'; + +export default function GroupInfoRoute(): ReactElement | null { + const params = useLocalSearchParams<{ id: string }>(); + const id = typeof params.id === 'string' ? params.id : null; + if (id === null) { + return null; + } + return ; +} diff --git a/packages/mobile/app/(app)/new/channel.tsx b/packages/mobile/app/(app)/new/channel.tsx new file mode 100644 index 0000000..bf1b62d --- /dev/null +++ b/packages/mobile/app/(app)/new/channel.tsx @@ -0,0 +1,6 @@ +import type { ReactElement } from 'react'; +import { NewChatWizard } from '@/features/conversations'; + +export default function NewChannelRoute(): ReactElement { + return ; +} diff --git a/packages/mobile/app/(app)/new/group.tsx b/packages/mobile/app/(app)/new/group.tsx new file mode 100644 index 0000000..59a4827 --- /dev/null +++ b/packages/mobile/app/(app)/new/group.tsx @@ -0,0 +1,6 @@ +import type { ReactElement } from 'react'; +import { NewChatWizard } from '@/features/conversations'; + +export default function NewGroupRoute(): ReactElement { + return ; +} diff --git a/packages/mobile/app/(app)/profile/[username].tsx b/packages/mobile/app/(app)/profile/[username].tsx new file mode 100644 index 0000000..8c3603f --- /dev/null +++ b/packages/mobile/app/(app)/profile/[username].tsx @@ -0,0 +1,12 @@ +import type { ReactElement } from 'react'; +import { useLocalSearchParams } from 'expo-router'; +import { ProfileScreen } from '@/features/profile'; + +export default function ProfileRoute(): ReactElement | null { + const params = useLocalSearchParams<{ username: string }>(); + const username = typeof params.username === 'string' ? params.username : null; + if (username === null || username.length === 0) { + return null; + } + return ; +} diff --git a/packages/mobile/app/(auth)/_layout.tsx b/packages/mobile/app/(auth)/_layout.tsx new file mode 100644 index 0000000..b57af23 --- /dev/null +++ b/packages/mobile/app/(auth)/_layout.tsx @@ -0,0 +1,6 @@ +import type { ReactElement } from 'react'; +import { Stack } from 'expo-router'; + +export default function AuthLayout(): ReactElement { + return ; +} diff --git a/packages/mobile/app/(auth)/sign-in.tsx b/packages/mobile/app/(auth)/sign-in.tsx new file mode 100644 index 0000000..e083cac --- /dev/null +++ b/packages/mobile/app/(auth)/sign-in.tsx @@ -0,0 +1,6 @@ +import type { ReactElement } from 'react'; +import { SignInScreen } from '@/features/auth'; + +export default function SignIn(): ReactElement { + return ; +} diff --git a/packages/mobile/app/_layout.tsx b/packages/mobile/app/_layout.tsx new file mode 100644 index 0000000..b321901 --- /dev/null +++ b/packages/mobile/app/_layout.tsx @@ -0,0 +1,65 @@ +import 'react-native-gesture-handler'; +import { useEffect } from 'react'; +import type { ReactElement } from 'react'; +import { ActivityIndicator, View } from 'react-native'; +import { Stack } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { KeyboardProvider } from 'react-native-keyboard-controller'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { setAudioModeAsync } from 'expo-audio'; +import { ThemeProvider, useTheme } from '@/theme'; +import { useSession } from '@/stores/session'; +import { ErrorBoundary } from '@/components'; + +// Voice notes must play even with the iPhone mute switch on (Telegram +// behavior) — without this, playback "works" but is silent on most devices. +void setAudioModeAsync({ playsInSilentMode: true }); + +const RootNavigator = (): ReactElement => { + const { colors, name } = useTheme(); + const status = useSession((s) => s.status); + const bootstrap = useSession((s) => s.bootstrap); + + useEffect(() => { + void bootstrap(); + }, [bootstrap]); + + if (status === 'loading') { + return ( + + + + ); + } + + return ( + <> + + + + + + + + + + + ); +}; + +export default function RootLayout(): ReactElement { + return ( + + + + + + + + + + + + ); +} diff --git a/packages/mobile/babel.config.js b/packages/mobile/babel.config.js new file mode 100644 index 0000000..4088e33 --- /dev/null +++ b/packages/mobile/babel.config.js @@ -0,0 +1,8 @@ +// Expo + Reanimated. The worklets plugin (Reanimated 4) MUST be listed last. +module.exports = function babel(api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + plugins: ['react-native-worklets/plugin'], + }; +}; diff --git a/packages/mobile/eslint.config.mjs b/packages/mobile/eslint.config.mjs index 56d4960..cad8674 100644 --- a/packages/mobile/eslint.config.mjs +++ b/packages/mobile/eslint.config.mjs @@ -1,3 +1,33 @@ import { base } from '../../eslint.config.mjs'; +import reactHooks from 'eslint-plugin-react-hooks'; +import globals from 'globals'; -export default base; +// Mobile (React Native + Expo). Same strict base as the rest of the repo, plus +// react-hooks rules and native globals. Route files live in app/ (thin), logic +// in src/ following FSD; the type-safety rules (no any/assertions/!) still apply. +export default [ + { + ignores: [ + '.expo/**', + 'android/**', + 'ios/**', + 'expo-env.d.ts', + 'metro.config.js', + 'babel.config.js', + ], + }, + ...base, + { + files: ['app/**/*.{ts,tsx}', 'src/**/*.{ts,tsx}'], + languageOptions: { + globals: { ...globals.browser }, + }, + plugins: { + 'react-hooks': reactHooks, + }, + rules: { + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + }, + }, +]; diff --git a/packages/mobile/expo-env.d.ts b/packages/mobile/expo-env.d.ts new file mode 100644 index 0000000..5411fdd --- /dev/null +++ b/packages/mobile/expo-env.d.ts @@ -0,0 +1,3 @@ +/// + +// NOTE: This file should not be edited and should be in your git ignore \ No newline at end of file diff --git a/packages/mobile/ios/.gitignore b/packages/mobile/ios/.gitignore new file mode 100644 index 0000000..8beb344 --- /dev/null +++ b/packages/mobile/ios/.gitignore @@ -0,0 +1,30 @@ +# OSX +# +.DS_Store + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +project.xcworkspace +.xcode.env.local + +# Bundle artifacts +*.jsbundle + +# CocoaPods +/Pods/ diff --git a/packages/mobile/ios/.xcode.env b/packages/mobile/ios/.xcode.env new file mode 100644 index 0000000..3d5782c --- /dev/null +++ b/packages/mobile/ios/.xcode.env @@ -0,0 +1,11 @@ +# This `.xcode.env` file is versioned and is used to source the environment +# used when running script phases inside Xcode. +# To customize your local environment, you can create an `.xcode.env.local` +# file that is not versioned. + +# NODE_BINARY variable contains the PATH to the node executable. +# +# Customize the NODE_BINARY variable here. +# For example, to use nvm with brew, add the following line +# . "$(brew --prefix nvm)/nvm.sh" --no-use +export NODE_BINARY=$(command -v node) diff --git a/packages/mobile/ios/Podfile b/packages/mobile/ios/Podfile new file mode 100644 index 0000000..67b9b6c --- /dev/null +++ b/packages/mobile/ios/Podfile @@ -0,0 +1,63 @@ +require File.join(File.dirname(`node --print "require.resolve('expo/package.json')"`), "scripts/autolinking") +require File.join(File.dirname(`node --print "require.resolve('react-native/package.json')"`), "scripts/react_native_pods") + +require 'json' +podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {} + +def ccache_enabled?(podfile_properties) + # Environment variable takes precedence + return ENV['USE_CCACHE'] == '1' if ENV['USE_CCACHE'] + + # Fall back to Podfile properties + podfile_properties['apple.ccacheEnabled'] == 'true' +end + +ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if podfile_properties['newArchEnabled'] == 'false' +ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] ||= podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR'] +ENV['RCT_USE_RN_DEP'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' && podfile_properties['newArchEnabled'] != 'false' +ENV['RCT_USE_PREBUILT_RNCORE'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' && podfile_properties['newArchEnabled'] != 'false' +platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1' + +prepare_react_native_project! + +target 'Zovi' do + use_expo_modules! + + if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] == '1' + config_command = ['node', '-e', "process.argv=['', '', 'config'];require('@react-native-community/cli').run()"]; + else + config_command = [ + 'node', + '--no-warnings', + '--eval', + 'require(\'expo/bin/autolinking\')', + 'expo-modules-autolinking', + 'react-native-config', + '--json', + '--platform', + 'ios' + ] + end + + config = use_native_modules!(config_command) + + use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks'] + use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS'] + + use_react_native!( + :path => config[:reactNativePath], + :hermes_enabled => podfile_properties['expo.jsEngine'] == nil || podfile_properties['expo.jsEngine'] == 'hermes', + # An absolute path to your application root. + :app_path => "#{Pod::Config.instance.installation_root}/..", + :privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false', + ) + + post_install do |installer| + react_native_post_install( + installer, + config[:reactNativePath], + :mac_catalyst_enabled => false, + :ccache_enabled => ccache_enabled?(podfile_properties), + ) + end +end diff --git a/packages/mobile/ios/Podfile.lock b/packages/mobile/ios/Podfile.lock new file mode 100644 index 0000000..96be1d8 --- /dev/null +++ b/packages/mobile/ios/Podfile.lock @@ -0,0 +1,2597 @@ +PODS: + - EXApplication (7.0.8): + - ExpoModulesCore + - EXConstants (18.0.13): + - ExpoModulesCore + - EXImageLoader (6.0.0): + - ExpoModulesCore + - React-Core + - EXNotifications (0.32.17): + - ExpoModulesCore + - Expo (54.0.35): + - ExpoModulesCore + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTAppDelegate + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactAppDependencyProvider + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - ExpoAsset (12.0.13): + - ExpoModulesCore + - ExpoAudio (1.1.1): + - ExpoModulesCore + - ExpoCamera (17.0.10): + - ExpoModulesCore + - ZXingObjC/OneD + - ZXingObjC/PDF417 + - ExpoClipboard (8.0.8): + - ExpoModulesCore + - ExpoCrypto (15.0.9): + - ExpoModulesCore + - ExpoDocumentPicker (14.0.8): + - ExpoModulesCore + - ExpoFileSystem (19.0.23): + - ExpoModulesCore + - ExpoFont (14.0.12): + - ExpoModulesCore + - ExpoHaptics (15.0.8): + - ExpoModulesCore + - ExpoHead (6.0.24): + - ExpoModulesCore + - RNScreens + - ExpoImage (3.0.11): + - ExpoModulesCore + - libavif/libdav1d + - SDWebImage (~> 5.21.0) + - SDWebImageAVIFCoder (~> 0.11.0) + - SDWebImageSVGCoder (~> 1.7.0) + - SDWebImageWebPCoder (~> 0.14.6) + - ExpoImagePicker (17.0.11): + - ExpoModulesCore + - ExpoKeepAwake (15.0.8): + - ExpoModulesCore + - ExpoLinking (8.0.12): + - ExpoModulesCore + - ExpoModulesCore (3.0.30): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-jsinspector + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - ExpoSecureStore (15.0.8): + - ExpoModulesCore + - ExpoSplashScreen (31.0.13): + - ExpoModulesCore + - ExpoSystemUI (6.0.9): + - ExpoModulesCore + - ExpoVideo (3.0.16): + - ExpoModulesCore + - ExpoVideoThumbnails (10.0.8): + - ExpoModulesCore + - FBLazyVector (0.81.4) + - hermes-engine (0.81.4): + - hermes-engine/Pre-built (= 0.81.4) + - hermes-engine/Pre-built (0.81.4) + - libavif/core (1.0.0) + - libavif/libdav1d (1.0.0): + - libavif/core + - libdav1d (>= 0.6.0) + - libdav1d (1.2.0) + - libwebp (1.5.0): + - libwebp/demux (= 1.5.0) + - libwebp/mux (= 1.5.0) + - libwebp/sharpyuv (= 1.5.0) + - libwebp/webp (= 1.5.0) + - libwebp/demux (1.5.0): + - libwebp/webp + - libwebp/mux (1.5.0): + - libwebp/demux + - libwebp/sharpyuv (1.5.0) + - libwebp/webp (1.5.0): + - libwebp/sharpyuv + - RCTDeprecation (0.81.4) + - RCTRequired (0.81.4) + - RCTTypeSafety (0.81.4): + - FBLazyVector (= 0.81.4) + - RCTRequired (= 0.81.4) + - React-Core (= 0.81.4) + - React (0.81.4): + - React-Core (= 0.81.4) + - React-Core/DevSupport (= 0.81.4) + - React-Core/RCTWebSocket (= 0.81.4) + - React-RCTActionSheet (= 0.81.4) + - React-RCTAnimation (= 0.81.4) + - React-RCTBlob (= 0.81.4) + - React-RCTImage (= 0.81.4) + - React-RCTLinking (= 0.81.4) + - React-RCTNetwork (= 0.81.4) + - React-RCTSettings (= 0.81.4) + - React-RCTText (= 0.81.4) + - React-RCTVibration (= 0.81.4) + - React-callinvoker (0.81.4) + - React-Core (0.81.4): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default (= 0.81.4) + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core-prebuilt (0.81.4): + - ReactNativeDependencies + - React-Core/CoreModulesHeaders (0.81.4): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/Default (0.81.4): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/DevSupport (0.81.4): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default (= 0.81.4) + - React-Core/RCTWebSocket (= 0.81.4) + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTActionSheetHeaders (0.81.4): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTAnimationHeaders (0.81.4): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTBlobHeaders (0.81.4): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTImageHeaders (0.81.4): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTLinkingHeaders (0.81.4): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTNetworkHeaders (0.81.4): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTSettingsHeaders (0.81.4): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTTextHeaders (0.81.4): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTVibrationHeaders (0.81.4): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTWebSocket (0.81.4): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default (= 0.81.4) + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-CoreModules (0.81.4): + - RCTTypeSafety (= 0.81.4) + - React-Core-prebuilt + - React-Core/CoreModulesHeaders (= 0.81.4) + - React-jsi (= 0.81.4) + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-NativeModulesApple + - React-RCTBlob + - React-RCTFBReactNativeSpec + - React-RCTImage (= 0.81.4) + - React-runtimeexecutor + - ReactCommon + - ReactNativeDependencies + - React-cxxreact (0.81.4): + - hermes-engine + - React-callinvoker (= 0.81.4) + - React-Core-prebuilt + - React-debug (= 0.81.4) + - React-jsi (= 0.81.4) + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-logger (= 0.81.4) + - React-perflogger (= 0.81.4) + - React-runtimeexecutor + - React-timing (= 0.81.4) + - ReactNativeDependencies + - React-debug (0.81.4) + - React-defaultsnativemodule (0.81.4): + - hermes-engine + - React-Core-prebuilt + - React-domnativemodule + - React-featureflagsnativemodule + - React-idlecallbacksnativemodule + - React-jsi + - React-jsiexecutor + - React-microtasksnativemodule + - React-RCTFBReactNativeSpec + - ReactNativeDependencies + - React-domnativemodule (0.81.4): + - hermes-engine + - React-Core-prebuilt + - React-Fabric + - React-Fabric/bridging + - React-FabricComponents + - React-graphics + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-Fabric (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/animations (= 0.81.4) + - React-Fabric/attributedstring (= 0.81.4) + - React-Fabric/bridging (= 0.81.4) + - React-Fabric/componentregistry (= 0.81.4) + - React-Fabric/componentregistrynative (= 0.81.4) + - React-Fabric/components (= 0.81.4) + - React-Fabric/consistency (= 0.81.4) + - React-Fabric/core (= 0.81.4) + - React-Fabric/dom (= 0.81.4) + - React-Fabric/imagemanager (= 0.81.4) + - React-Fabric/leakchecker (= 0.81.4) + - React-Fabric/mounting (= 0.81.4) + - React-Fabric/observers (= 0.81.4) + - React-Fabric/scheduler (= 0.81.4) + - React-Fabric/telemetry (= 0.81.4) + - React-Fabric/templateprocessor (= 0.81.4) + - React-Fabric/uimanager (= 0.81.4) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/animations (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/attributedstring (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/bridging (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/componentregistry (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/componentregistrynative (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/components/legacyviewmanagerinterop (= 0.81.4) + - React-Fabric/components/root (= 0.81.4) + - React-Fabric/components/scrollview (= 0.81.4) + - React-Fabric/components/view (= 0.81.4) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components/legacyviewmanagerinterop (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components/root (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components/scrollview (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components/view (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-renderercss + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-Fabric/consistency (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/core (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/dom (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/imagemanager (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/leakchecker (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/mounting (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/observers (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/observers/events (= 0.81.4) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/observers/events (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/scheduler (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/observers/events + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-performancetimeline + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/telemetry (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/templateprocessor (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/uimanager (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/uimanager/consistency (= 0.81.4) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererconsistency + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/uimanager/consistency (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererconsistency + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-FabricComponents (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-FabricComponents/components (= 0.81.4) + - React-FabricComponents/textlayoutmanager (= 0.81.4) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-FabricComponents/components/inputaccessory (= 0.81.4) + - React-FabricComponents/components/iostextinput (= 0.81.4) + - React-FabricComponents/components/modal (= 0.81.4) + - React-FabricComponents/components/rncore (= 0.81.4) + - React-FabricComponents/components/safeareaview (= 0.81.4) + - React-FabricComponents/components/scrollview (= 0.81.4) + - React-FabricComponents/components/switch (= 0.81.4) + - React-FabricComponents/components/text (= 0.81.4) + - React-FabricComponents/components/textinput (= 0.81.4) + - React-FabricComponents/components/unimplementedview (= 0.81.4) + - React-FabricComponents/components/virtualview (= 0.81.4) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/inputaccessory (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/iostextinput (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/modal (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/rncore (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/safeareaview (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/scrollview (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/switch (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/text (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/textinput (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/unimplementedview (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/virtualview (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/textlayoutmanager (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricImage (0.81.4): + - hermes-engine + - RCTRequired (= 0.81.4) + - RCTTypeSafety (= 0.81.4) + - React-Core-prebuilt + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-jsiexecutor (= 0.81.4) + - React-logger + - React-rendererdebug + - React-utils + - ReactCommon + - ReactNativeDependencies + - Yoga + - React-featureflags (0.81.4): + - React-Core-prebuilt + - ReactNativeDependencies + - React-featureflagsnativemodule (0.81.4): + - hermes-engine + - React-Core-prebuilt + - React-featureflags + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-graphics (0.81.4): + - hermes-engine + - React-Core-prebuilt + - React-jsi + - React-jsiexecutor + - React-utils + - ReactNativeDependencies + - React-hermes (0.81.4): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact (= 0.81.4) + - React-jsi + - React-jsiexecutor (= 0.81.4) + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-perflogger (= 0.81.4) + - React-runtimeexecutor + - ReactNativeDependencies + - React-idlecallbacksnativemodule (0.81.4): + - hermes-engine + - React-Core-prebuilt + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - React-runtimescheduler + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-ImageManager (0.81.4): + - React-Core-prebuilt + - React-Core/Default + - React-debug + - React-Fabric + - React-graphics + - React-rendererdebug + - React-utils + - ReactNativeDependencies + - React-jserrorhandler (0.81.4): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-jsi + - ReactCommon/turbomodule/bridging + - ReactNativeDependencies + - React-jsi (0.81.4): + - hermes-engine + - React-Core-prebuilt + - ReactNativeDependencies + - React-jsiexecutor (0.81.4): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact (= 0.81.4) + - React-jsi (= 0.81.4) + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-perflogger (= 0.81.4) + - React-runtimeexecutor + - ReactNativeDependencies + - React-jsinspector (0.81.4): + - hermes-engine + - React-Core-prebuilt + - React-featureflags + - React-jsi + - React-jsinspectorcdp + - React-jsinspectornetwork + - React-jsinspectortracing + - React-oscompat + - React-perflogger (= 0.81.4) + - React-runtimeexecutor + - ReactNativeDependencies + - React-jsinspectorcdp (0.81.4): + - React-Core-prebuilt + - ReactNativeDependencies + - React-jsinspectornetwork (0.81.4): + - React-Core-prebuilt + - React-featureflags + - React-jsinspectorcdp + - React-performancetimeline + - React-timing + - ReactNativeDependencies + - React-jsinspectortracing (0.81.4): + - React-Core-prebuilt + - React-oscompat + - React-timing + - ReactNativeDependencies + - React-jsitooling (0.81.4): + - React-Core-prebuilt + - React-cxxreact (= 0.81.4) + - React-jsi (= 0.81.4) + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-runtimeexecutor + - ReactNativeDependencies + - React-jsitracing (0.81.4): + - React-jsi + - React-logger (0.81.4): + - React-Core-prebuilt + - ReactNativeDependencies + - React-Mapbuffer (0.81.4): + - React-Core-prebuilt + - React-debug + - ReactNativeDependencies + - React-microtasksnativemodule (0.81.4): + - hermes-engine + - React-Core-prebuilt + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - react-native-keyboard-controller (1.22.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - react-native-keyboard-controller/common (= 1.22.0) + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-keyboard-controller/common (1.22.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-safe-area-context (5.6.2): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - react-native-safe-area-context/common (= 5.6.2) + - react-native-safe-area-context/fabric (= 5.6.2) + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-safe-area-context/common (5.6.2): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-safe-area-context/fabric (5.6.2): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - react-native-safe-area-context/common + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-NativeModulesApple (0.81.4): + - hermes-engine + - React-callinvoker + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-featureflags + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-runtimeexecutor + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-oscompat (0.81.4) + - React-perflogger (0.81.4): + - React-Core-prebuilt + - ReactNativeDependencies + - React-performancetimeline (0.81.4): + - React-Core-prebuilt + - React-featureflags + - React-jsinspectortracing + - React-perflogger + - React-timing + - ReactNativeDependencies + - React-RCTActionSheet (0.81.4): + - React-Core/RCTActionSheetHeaders (= 0.81.4) + - React-RCTAnimation (0.81.4): + - RCTTypeSafety + - React-Core-prebuilt + - React-Core/RCTAnimationHeaders + - React-featureflags + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactNativeDependencies + - React-RCTAppDelegate (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-CoreModules + - React-debug + - React-defaultsnativemodule + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-jsitooling + - React-NativeModulesApple + - React-RCTFabric + - React-RCTFBReactNativeSpec + - React-RCTImage + - React-RCTNetwork + - React-RCTRuntime + - React-rendererdebug + - React-RuntimeApple + - React-RuntimeCore + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon + - ReactNativeDependencies + - React-RCTBlob (0.81.4): + - hermes-engine + - React-Core-prebuilt + - React-Core/RCTBlobHeaders + - React-Core/RCTWebSocket + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - React-RCTNetwork + - ReactCommon + - ReactNativeDependencies + - React-RCTFabric (0.81.4): + - hermes-engine + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-FabricComponents + - React-FabricImage + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectornetwork + - React-jsinspectortracing + - React-performancetimeline + - React-RCTAnimation + - React-RCTFBReactNativeSpec + - React-RCTImage + - React-RCTText + - React-rendererconsistency + - React-renderercss + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-RCTFBReactNativeSpec (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec/components (= 0.81.4) + - ReactCommon + - ReactNativeDependencies + - React-RCTFBReactNativeSpec/components (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-NativeModulesApple + - React-rendererdebug + - React-utils + - ReactCommon + - ReactNativeDependencies + - Yoga + - React-RCTImage (0.81.4): + - RCTTypeSafety + - React-Core-prebuilt + - React-Core/RCTImageHeaders + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - React-RCTNetwork + - ReactCommon + - ReactNativeDependencies + - React-RCTLinking (0.81.4): + - React-Core/RCTLinkingHeaders (= 0.81.4) + - React-jsi (= 0.81.4) + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactCommon/turbomodule/core (= 0.81.4) + - React-RCTNetwork (0.81.4): + - RCTTypeSafety + - React-Core-prebuilt + - React-Core/RCTNetworkHeaders + - React-featureflags + - React-jsi + - React-jsinspectorcdp + - React-jsinspectornetwork + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactNativeDependencies + - React-RCTRuntime (0.81.4): + - hermes-engine + - React-Core + - React-Core-prebuilt + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-jsitooling + - React-RuntimeApple + - React-RuntimeCore + - React-runtimeexecutor + - React-RuntimeHermes + - ReactNativeDependencies + - React-RCTSettings (0.81.4): + - RCTTypeSafety + - React-Core-prebuilt + - React-Core/RCTSettingsHeaders + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactNativeDependencies + - React-RCTText (0.81.4): + - React-Core/RCTTextHeaders (= 0.81.4) + - Yoga + - React-RCTVibration (0.81.4): + - React-Core-prebuilt + - React-Core/RCTVibrationHeaders + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactNativeDependencies + - React-rendererconsistency (0.81.4) + - React-renderercss (0.81.4): + - React-debug + - React-utils + - React-rendererdebug (0.81.4): + - React-Core-prebuilt + - React-debug + - ReactNativeDependencies + - React-RuntimeApple (0.81.4): + - hermes-engine + - React-callinvoker + - React-Core-prebuilt + - React-Core/Default + - React-CoreModules + - React-cxxreact + - React-featureflags + - React-jserrorhandler + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-Mapbuffer + - React-NativeModulesApple + - React-RCTFabric + - React-RCTFBReactNativeSpec + - React-RuntimeCore + - React-runtimeexecutor + - React-RuntimeHermes + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - React-RuntimeCore (0.81.4): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact + - React-Fabric + - React-featureflags + - React-jserrorhandler + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-performancetimeline + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - React-runtimeexecutor (0.81.4): + - React-Core-prebuilt + - React-debug + - React-featureflags + - React-jsi (= 0.81.4) + - React-utils + - ReactNativeDependencies + - React-RuntimeHermes (0.81.4): + - hermes-engine + - React-Core-prebuilt + - React-featureflags + - React-hermes + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-jsitooling + - React-jsitracing + - React-RuntimeCore + - React-runtimeexecutor + - React-utils + - ReactNativeDependencies + - React-runtimescheduler (0.81.4): + - hermes-engine + - React-callinvoker + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-jsi + - React-jsinspectortracing + - React-performancetimeline + - React-rendererconsistency + - React-rendererdebug + - React-runtimeexecutor + - React-timing + - React-utils + - ReactNativeDependencies + - React-timing (0.81.4): + - React-debug + - React-utils (0.81.4): + - hermes-engine + - React-Core-prebuilt + - React-debug + - React-jsi (= 0.81.4) + - ReactNativeDependencies + - ReactAppDependencyProvider (0.81.4): + - ReactCodegen + - ReactCodegen (0.81.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-FabricImage + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-NativeModulesApple + - React-RCTAppDelegate + - React-rendererdebug + - React-utils + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - ReactCommon (0.81.4): + - React-Core-prebuilt + - ReactCommon/turbomodule (= 0.81.4) + - ReactNativeDependencies + - ReactCommon/turbomodule (0.81.4): + - hermes-engine + - React-callinvoker (= 0.81.4) + - React-Core-prebuilt + - React-cxxreact (= 0.81.4) + - React-jsi (= 0.81.4) + - React-logger (= 0.81.4) + - React-perflogger (= 0.81.4) + - ReactCommon/turbomodule/bridging (= 0.81.4) + - ReactCommon/turbomodule/core (= 0.81.4) + - ReactNativeDependencies + - ReactCommon/turbomodule/bridging (0.81.4): + - hermes-engine + - React-callinvoker (= 0.81.4) + - React-Core-prebuilt + - React-cxxreact (= 0.81.4) + - React-jsi (= 0.81.4) + - React-logger (= 0.81.4) + - React-perflogger (= 0.81.4) + - ReactNativeDependencies + - ReactCommon/turbomodule/core (0.81.4): + - hermes-engine + - React-callinvoker (= 0.81.4) + - React-Core-prebuilt + - React-cxxreact (= 0.81.4) + - React-debug (= 0.81.4) + - React-featureflags (= 0.81.4) + - React-jsi (= 0.81.4) + - React-logger (= 0.81.4) + - React-perflogger (= 0.81.4) + - React-utils (= 0.81.4) + - ReactNativeDependencies + - ReactNativeDependencies (0.81.4) + - RNCAsyncStorage (2.2.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - RNGestureHandler (2.28.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - RNReanimated (4.1.7): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNReanimated/reanimated (= 4.1.7) + - RNWorklets + - Yoga + - RNReanimated/reanimated (4.1.7): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNReanimated/reanimated/apple (= 4.1.7) + - RNWorklets + - Yoga + - RNReanimated/reanimated/apple (4.1.7): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNWorklets + - Yoga + - RNScreens (4.16.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-RCTImage + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNScreens/common (= 4.16.0) + - Yoga + - RNScreens/common (4.16.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-RCTImage + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - RNSVG (15.12.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNSVG/common (= 15.12.1) + - Yoga + - RNSVG/common (15.12.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - RNWorklets (0.5.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNWorklets/worklets (= 0.5.1) + - Yoga + - RNWorklets/worklets (0.5.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNWorklets/worklets/apple (= 0.5.1) + - Yoga + - RNWorklets/worklets/apple (0.5.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - SDWebImage (5.21.7): + - SDWebImage/Core (= 5.21.7) + - SDWebImage/Core (5.21.7) + - SDWebImageAVIFCoder (0.11.1): + - libavif/core (>= 0.11.0) + - SDWebImage (~> 5.10) + - SDWebImageSVGCoder (1.7.0): + - SDWebImage/Core (~> 5.6) + - SDWebImageWebPCoder (0.14.6): + - libwebp (~> 1.0) + - SDWebImage/Core (~> 5.17) + - Yoga (0.0.0) + - ZXingObjC/Core (3.6.9) + - ZXingObjC/OneD (3.6.9): + - ZXingObjC/Core + - ZXingObjC/PDF417 (3.6.9): + - ZXingObjC/Core + +DEPENDENCIES: + - "EXApplication (from `../../../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.35/node_modules/expo-application/ios`)" + - "EXConstants (from `../../../node_modules/.pnpm/expo-constants@18.0.13_expo@54.0.35_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0_/node_modules/expo-constants/ios`)" + - "EXImageLoader (from `../../../node_modules/.pnpm/expo-image-loader@6.0.0_expo@54.0.35/node_modules/expo-image-loader/ios`)" + - "EXNotifications (from `../../../node_modules/.pnpm/expo-notifications@0.32.17_expo@54.0.35_react-native@0.81.4_@babel+core@7.29.7_@types+react@1_s6funicimmaxzsbo77tk5cbyz4/node_modules/expo-notifications/ios`)" + - "Expo (from `../../../node_modules/.pnpm/expo@54.0.35_@babel+core@7.29.7_@expo+metro-runtime@6.1.2_expo-router@6.0.24_react-native@0.8_jneaa6dg23lcrkkzmw4cjloi2q/node_modules/expo`)" + - "ExpoAsset (from `../../../node_modules/.pnpm/expo-asset@12.0.13_expo@54.0.35_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_r_cpwa4wav3mbscdya3zoj7un4qe/node_modules/expo-asset/ios`)" + - "ExpoAudio (from `../../../node_modules/.pnpm/expo-audio@1.1.1_expo-asset@12.0.13_expo@54.0.35_react-native@0.81.4_@babel+core@7.29.7_@type_royxvy3qljv5rh5f3vhdpfynku/node_modules/expo-audio/ios`)" + - "ExpoCamera (from `../../../node_modules/.pnpm/expo-camera@17.0.10_expo@54.0.35_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-camera/ios`)" + - "ExpoClipboard (from `../../../node_modules/.pnpm/expo-clipboard@8.0.8_expo@54.0.35_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-clipboard/ios`)" + - "ExpoCrypto (from `../../../node_modules/.pnpm/expo-crypto@15.0.9_expo@54.0.35/node_modules/expo-crypto/ios`)" + - "ExpoDocumentPicker (from `../../../node_modules/.pnpm/expo-document-picker@14.0.8_expo@54.0.35/node_modules/expo-document-picker/ios`)" + - "ExpoFileSystem (from `../../../node_modules/.pnpm/expo-file-system@19.0.23_expo@54.0.35_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios`)" + - "ExpoFont (from `../../../node_modules/.pnpm/expo-font@14.0.12_expo@54.0.35_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-font/ios`)" + - "ExpoHaptics (from `../../../node_modules/.pnpm/expo-haptics@15.0.8_expo@54.0.35/node_modules/expo-haptics/ios`)" + - "ExpoHead (from `../../../node_modules/.pnpm/expo-router@6.0.24_@expo+metro-runtime@6.1.2_@types+react-dom@19.2.3_@types+react@19.1.17__@t_klckwfqs6emhjwwpx3ddqshddi/node_modules/expo-router/ios`)" + - "ExpoImage (from `../../../node_modules/.pnpm/expo-image@3.0.11_expo@54.0.35_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-image/ios`)" + - "ExpoImagePicker (from `../../../node_modules/.pnpm/expo-image-picker@17.0.11_expo@54.0.35/node_modules/expo-image-picker/ios`)" + - "ExpoKeepAwake (from `../../../node_modules/.pnpm/expo-keep-awake@15.0.8_expo@54.0.35_react@19.1.0/node_modules/expo-keep-awake/ios`)" + - "ExpoLinking (from `../../../node_modules/.pnpm/expo-linking@8.0.12_expo@54.0.35_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-linking/ios`)" + - "ExpoModulesCore (from `../../../node_modules/.pnpm/expo-modules-core@3.0.30_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-modules-core`)" + - "ExpoSecureStore (from `../../../node_modules/.pnpm/expo-secure-store@15.0.8_expo@54.0.35/node_modules/expo-secure-store/ios`)" + - "ExpoSplashScreen (from `../../../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.35_typescript@5.9.2/node_modules/expo-splash-screen/ios`)" + - "ExpoSystemUI (from `../../../node_modules/.pnpm/expo-system-ui@6.0.9_expo@54.0.35_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0_/node_modules/expo-system-ui/ios`)" + - "ExpoVideo (from `../../../node_modules/.pnpm/expo-video@3.0.16_expo@54.0.35_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-video/ios`)" + - "ExpoVideoThumbnails (from `../../../node_modules/.pnpm/expo-video-thumbnails@10.0.8_expo@54.0.35/node_modules/expo-video-thumbnails/ios`)" + - "FBLazyVector (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/FBLazyVector`)" + - "hermes-engine (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)" + - "RCTDeprecation (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)" + - "RCTRequired (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Required`)" + - "RCTTypeSafety (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/TypeSafety`)" + - "React (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/`)" + - "React-callinvoker (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/callinvoker`)" + - "React-Core (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/`)" + - "React-Core-prebuilt (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React-Core-prebuilt.podspec`)" + - "React-Core/RCTWebSocket (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/`)" + - "React-CoreModules (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React/CoreModules`)" + - "React-cxxreact (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/cxxreact`)" + - "React-debug (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/debug`)" + - "React-defaultsnativemodule (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/defaults`)" + - "React-domnativemodule (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/dom`)" + - "React-Fabric (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)" + - "React-FabricComponents (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)" + - "React-FabricImage (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)" + - "React-featureflags (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/featureflags`)" + - "React-featureflagsnativemodule (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/featureflags`)" + - "React-graphics (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/graphics`)" + - "React-hermes (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/hermes`)" + - "React-idlecallbacksnativemodule (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`)" + - "React-ImageManager (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`)" + - "React-jserrorhandler (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jserrorhandler`)" + - "React-jsi (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsi`)" + - "React-jsiexecutor (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsiexecutor`)" + - "React-jsinspector (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern`)" + - "React-jsinspectorcdp (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/cdp`)" + - "React-jsinspectornetwork (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/network`)" + - "React-jsinspectortracing (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/tracing`)" + - "React-jsitooling (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsitooling`)" + - "React-jsitracing (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/hermes/executor/`)" + - "React-logger (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/logger`)" + - "React-Mapbuffer (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)" + - "React-microtasksnativemodule (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)" + - "react-native-keyboard-controller (from `../../../node_modules/.pnpm/react-native-keyboard-controller@1.22.0_react-native-reanimated@4.1.7_react-native-worklets@0_jcwvv4t6i4mwiedl3fnuzu6ni4/node_modules/react-native-keyboard-controller`)" + - "react-native-safe-area-context (from `../../../node_modules/.pnpm/react-native-safe-area-context@5.6.2_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1_dm3gujeglwqcw6r2emmfvxxqbi/node_modules/react-native-safe-area-context`)" + - "React-NativeModulesApple (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)" + - "React-oscompat (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/oscompat`)" + - "React-perflogger (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/reactperflogger`)" + - "React-performancetimeline (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/performance/timeline`)" + - "React-RCTActionSheet (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/ActionSheetIOS`)" + - "React-RCTAnimation (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/NativeAnimation`)" + - "React-RCTAppDelegate (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/AppDelegate`)" + - "React-RCTBlob (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Blob`)" + - "React-RCTFabric (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React`)" + - "React-RCTFBReactNativeSpec (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React`)" + - "React-RCTImage (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Image`)" + - "React-RCTLinking (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/LinkingIOS`)" + - "React-RCTNetwork (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Network`)" + - "React-RCTRuntime (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React/Runtime`)" + - "React-RCTSettings (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Settings`)" + - "React-RCTText (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Text`)" + - "React-RCTVibration (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Vibration`)" + - "React-rendererconsistency (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/consistency`)" + - "React-renderercss (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/css`)" + - "React-rendererdebug (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/debug`)" + - "React-RuntimeApple (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime/platform/ios`)" + - "React-RuntimeCore (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime`)" + - "React-runtimeexecutor (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/runtimeexecutor`)" + - "React-RuntimeHermes (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime`)" + - "React-runtimescheduler (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`)" + - "React-timing (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/timing`)" + - "React-utils (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/utils`)" + - ReactAppDependencyProvider (from `build/generated/ios`) + - ReactCodegen (from `build/generated/ios`) + - "ReactCommon/turbomodule/core (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon`)" + - "ReactNativeDependencies (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`)" + - "RNCAsyncStorage (from `../../../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.4_@babel+core@7.29.7_@types_qbuytpmhixgq7eutafycidcelu/node_modules/@react-native-async-storage/async-storage`)" + - "RNGestureHandler (from `../../../node_modules/.pnpm/react-native-gesture-handler@2.28.0_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1._olx3ym3ixvu4fxvzpb7m7etwle/node_modules/react-native-gesture-handler`)" + - "RNReanimated (from `../../../node_modules/.pnpm/react-native-reanimated@4.1.7_react-native-worklets@0.5.1_@babel+core@7.29.7_react-native@0.8_qnsugooid3rfwm6enzavt24f7u/node_modules/react-native-reanimated`)" + - "RNScreens (from `../../../node_modules/.pnpm/react-native-screens@4.16.0_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-screens`)" + - "RNSVG (from `../../../node_modules/.pnpm/react-native-svg@15.12.1_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-svg`)" + - "RNWorklets (from `../../../node_modules/.pnpm/react-native-worklets@0.5.1_@babel+core@7.29.7_react-native@0.81.4_@babel+core@7.29.7_@types+_72xeat46ll6ebpslafrafvzwcm/node_modules/react-native-worklets`)" + - "Yoga (from `../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/yoga`)" + +SPEC REPOS: + trunk: + - libavif + - libdav1d + - libwebp + - SDWebImage + - SDWebImageAVIFCoder + - SDWebImageSVGCoder + - SDWebImageWebPCoder + - ZXingObjC + +EXTERNAL SOURCES: + EXApplication: + :path: "../../../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.35/node_modules/expo-application/ios" + EXConstants: + :path: "../../../node_modules/.pnpm/expo-constants@18.0.13_expo@54.0.35_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0_/node_modules/expo-constants/ios" + EXImageLoader: + :path: "../../../node_modules/.pnpm/expo-image-loader@6.0.0_expo@54.0.35/node_modules/expo-image-loader/ios" + EXNotifications: + :path: "../../../node_modules/.pnpm/expo-notifications@0.32.17_expo@54.0.35_react-native@0.81.4_@babel+core@7.29.7_@types+react@1_s6funicimmaxzsbo77tk5cbyz4/node_modules/expo-notifications/ios" + Expo: + :path: "../../../node_modules/.pnpm/expo@54.0.35_@babel+core@7.29.7_@expo+metro-runtime@6.1.2_expo-router@6.0.24_react-native@0.8_jneaa6dg23lcrkkzmw4cjloi2q/node_modules/expo" + ExpoAsset: + :path: "../../../node_modules/.pnpm/expo-asset@12.0.13_expo@54.0.35_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_r_cpwa4wav3mbscdya3zoj7un4qe/node_modules/expo-asset/ios" + ExpoAudio: + :path: "../../../node_modules/.pnpm/expo-audio@1.1.1_expo-asset@12.0.13_expo@54.0.35_react-native@0.81.4_@babel+core@7.29.7_@type_royxvy3qljv5rh5f3vhdpfynku/node_modules/expo-audio/ios" + ExpoCamera: + :path: "../../../node_modules/.pnpm/expo-camera@17.0.10_expo@54.0.35_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-camera/ios" + ExpoClipboard: + :path: "../../../node_modules/.pnpm/expo-clipboard@8.0.8_expo@54.0.35_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-clipboard/ios" + ExpoCrypto: + :path: "../../../node_modules/.pnpm/expo-crypto@15.0.9_expo@54.0.35/node_modules/expo-crypto/ios" + ExpoDocumentPicker: + :path: "../../../node_modules/.pnpm/expo-document-picker@14.0.8_expo@54.0.35/node_modules/expo-document-picker/ios" + ExpoFileSystem: + :path: "../../../node_modules/.pnpm/expo-file-system@19.0.23_expo@54.0.35_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios" + ExpoFont: + :path: "../../../node_modules/.pnpm/expo-font@14.0.12_expo@54.0.35_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-font/ios" + ExpoHaptics: + :path: "../../../node_modules/.pnpm/expo-haptics@15.0.8_expo@54.0.35/node_modules/expo-haptics/ios" + ExpoHead: + :path: "../../../node_modules/.pnpm/expo-router@6.0.24_@expo+metro-runtime@6.1.2_@types+react-dom@19.2.3_@types+react@19.1.17__@t_klckwfqs6emhjwwpx3ddqshddi/node_modules/expo-router/ios" + ExpoImage: + :path: "../../../node_modules/.pnpm/expo-image@3.0.11_expo@54.0.35_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-image/ios" + ExpoImagePicker: + :path: "../../../node_modules/.pnpm/expo-image-picker@17.0.11_expo@54.0.35/node_modules/expo-image-picker/ios" + ExpoKeepAwake: + :path: "../../../node_modules/.pnpm/expo-keep-awake@15.0.8_expo@54.0.35_react@19.1.0/node_modules/expo-keep-awake/ios" + ExpoLinking: + :path: "../../../node_modules/.pnpm/expo-linking@8.0.12_expo@54.0.35_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-linking/ios" + ExpoModulesCore: + :path: "../../../node_modules/.pnpm/expo-modules-core@3.0.30_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-modules-core" + ExpoSecureStore: + :path: "../../../node_modules/.pnpm/expo-secure-store@15.0.8_expo@54.0.35/node_modules/expo-secure-store/ios" + ExpoSplashScreen: + :path: "../../../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.35_typescript@5.9.2/node_modules/expo-splash-screen/ios" + ExpoSystemUI: + :path: "../../../node_modules/.pnpm/expo-system-ui@6.0.9_expo@54.0.35_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0_/node_modules/expo-system-ui/ios" + ExpoVideo: + :path: "../../../node_modules/.pnpm/expo-video@3.0.16_expo@54.0.35_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-video/ios" + ExpoVideoThumbnails: + :path: "../../../node_modules/.pnpm/expo-video-thumbnails@10.0.8_expo@54.0.35/node_modules/expo-video-thumbnails/ios" + FBLazyVector: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/FBLazyVector" + hermes-engine: + :podspec: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" + :tag: hermes-2025-07-07-RNv0.81.0-e0fc67142ec0763c6b6153ca2bf96df815539782 + RCTDeprecation: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation" + RCTRequired: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Required" + RCTTypeSafety: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/TypeSafety" + React: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/" + React-callinvoker: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/callinvoker" + React-Core: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/" + React-Core-prebuilt: + :podspec: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React-Core-prebuilt.podspec" + React-CoreModules: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React/CoreModules" + React-cxxreact: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/cxxreact" + React-debug: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/debug" + React-defaultsnativemodule: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/defaults" + React-domnativemodule: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/dom" + React-Fabric: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon" + React-FabricComponents: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon" + React-FabricImage: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon" + React-featureflags: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/featureflags" + React-featureflagsnativemodule: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/featureflags" + React-graphics: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/graphics" + React-hermes: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/hermes" + React-idlecallbacksnativemodule: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks" + React-ImageManager: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios" + React-jserrorhandler: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jserrorhandler" + React-jsi: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsi" + React-jsiexecutor: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsiexecutor" + React-jsinspector: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern" + React-jsinspectorcdp: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/cdp" + React-jsinspectornetwork: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/network" + React-jsinspectortracing: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsinspector-modern/tracing" + React-jsitooling: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/jsitooling" + React-jsitracing: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/hermes/executor/" + React-logger: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/logger" + React-Mapbuffer: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon" + React-microtasksnativemodule: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/microtasks" + react-native-keyboard-controller: + :path: "../../../node_modules/.pnpm/react-native-keyboard-controller@1.22.0_react-native-reanimated@4.1.7_react-native-worklets@0_jcwvv4t6i4mwiedl3fnuzu6ni4/node_modules/react-native-keyboard-controller" + react-native-safe-area-context: + :path: "../../../node_modules/.pnpm/react-native-safe-area-context@5.6.2_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1_dm3gujeglwqcw6r2emmfvxxqbi/node_modules/react-native-safe-area-context" + React-NativeModulesApple: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" + React-oscompat: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/oscompat" + React-perflogger: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/reactperflogger" + React-performancetimeline: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/performance/timeline" + React-RCTActionSheet: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/ActionSheetIOS" + React-RCTAnimation: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/NativeAnimation" + React-RCTAppDelegate: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/AppDelegate" + React-RCTBlob: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Blob" + React-RCTFabric: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React" + React-RCTFBReactNativeSpec: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React" + React-RCTImage: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Image" + React-RCTLinking: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/LinkingIOS" + React-RCTNetwork: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Network" + React-RCTRuntime: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/React/Runtime" + React-RCTSettings: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Settings" + React-RCTText: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Text" + React-RCTVibration: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/Libraries/Vibration" + React-rendererconsistency: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/consistency" + React-renderercss: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/css" + React-rendererdebug: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/debug" + React-RuntimeApple: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime/platform/ios" + React-RuntimeCore: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime" + React-runtimeexecutor: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/runtimeexecutor" + React-RuntimeHermes: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/runtime" + React-runtimescheduler: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/renderer/runtimescheduler" + React-timing: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/timing" + React-utils: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/react/utils" + ReactAppDependencyProvider: + :path: build/generated/ios + ReactCodegen: + :path: build/generated/ios + ReactCommon: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon" + ReactNativeDependencies: + :podspec: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec" + RNCAsyncStorage: + :path: "../../../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.4_@babel+core@7.29.7_@types_qbuytpmhixgq7eutafycidcelu/node_modules/@react-native-async-storage/async-storage" + RNGestureHandler: + :path: "../../../node_modules/.pnpm/react-native-gesture-handler@2.28.0_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1._olx3ym3ixvu4fxvzpb7m7etwle/node_modules/react-native-gesture-handler" + RNReanimated: + :path: "../../../node_modules/.pnpm/react-native-reanimated@4.1.7_react-native-worklets@0.5.1_@babel+core@7.29.7_react-native@0.8_qnsugooid3rfwm6enzavt24f7u/node_modules/react-native-reanimated" + RNScreens: + :path: "../../../node_modules/.pnpm/react-native-screens@4.16.0_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-screens" + RNSVG: + :path: "../../../node_modules/.pnpm/react-native-svg@15.12.1_react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-svg" + RNWorklets: + :path: "../../../node_modules/.pnpm/react-native-worklets@0.5.1_@babel+core@7.29.7_react-native@0.81.4_@babel+core@7.29.7_@types+_72xeat46ll6ebpslafrafvzwcm/node_modules/react-native-worklets" + Yoga: + :path: "../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/yoga" + +SPEC CHECKSUMS: + EXApplication: 1e98d4b1dccdf30627f92917f4b2c5a53c330e5f + EXConstants: fce59a631a06c4151602843667f7cfe35f81e271 + EXImageLoader: 189e3476581efe3ad4d1d3fb4735b7179eb26f05 + EXNotifications: c67e92b9fdfe785ff9dbdc5f4c0ff935de357da7 + Expo: edf5879dd9f83d2aa5b05d012412c810bc182ac1 + ExpoAsset: 9e5a7c9c0a9fb83ad5b2bb365141f2bd7f289b66 + ExpoAudio: e4cfe3a2f3317b8487460685385a9867a07fb4fb + ExpoCamera: 6a326deb45ba840749652e4c15198317aa78497e + ExpoClipboard: b36b287d8356887844bb08ed5c84b5979bb4dd1e + ExpoCrypto: e2a2c4c748aac8485e5675f027b7901f511d43ff + ExpoDocumentPicker: 7cd9e71a0f66fb19eb0a586d6f26eee1284692e0 + ExpoFileSystem: a8e7fb1e958471764fb48d281cf9fa81a9e4d77c + ExpoFont: dfb7c572372b610da45b41e3b436321fd6a97215 + ExpoHaptics: d3a6375d8dcc3a1083d003bc2298ff654fafb536 + ExpoHead: 33924c5829f9a3f98a5bd432d23c0e02ef64afcc + ExpoImage: 686f972bff29525733aa13357f6691dc90aa03d8 + ExpoImagePicker: ade85e148f735fc7e6f27dfe3eef28eb3887e90a + ExpoKeepAwake: 55f75eca6499bb9e4231ebad6f3e9cb8f99c0296 + ExpoLinking: 2ef632ad5013e3f1568fae9408d56ab9af5efc55 + ExpoModulesCore: 9e6a5514828e7dd5ded9e99d33557be2d284e660 + ExpoSecureStore: d32f751874a2ceb5aaeebeb3578e165c1ba2b24a + ExpoSplashScreen: bc3cffefca2716e5f22350ca109badd7e50ec14d + ExpoSystemUI: 2ad325f361a2fcd96a464e8574e19935c461c9cc + ExpoVideo: 8d9e8a1acd0c4c210e538b4355e34ae8ff5e04f7 + ExpoVideoThumbnails: 503a79271416c8723f04b55ea4737282513ebe4f + FBLazyVector: 9e0cd874afd81d9a4d36679daca991b58b260d42 + hermes-engine: 35c763d57c9832d0eef764316ca1c4d043581394 + libavif: 5f8e715bea24debec477006f21ef9e95432e254d + libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f + libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 + RCTDeprecation: 7487d6dda857ccd4cb3dd6ecfccdc3170e85dcbc + RCTRequired: 54128b7df8be566881d48c7234724a78cb9b6157 + RCTTypeSafety: d2b07797a79e45d7b19e1cd2f53c79ab419fe217 + React: 2073376f47c71b7e9a0af7535986a77522ce1049 + React-callinvoker: 00fa0972a70df7408a4f088144b67207b157e386 + React-Core: d375dd308561785c739a621a21802e5e7e047dee + React-Core-prebuilt: dde79b89f8863efebb1d532a3335f472927da669 + React-CoreModules: 3eb9b1410a317987c557afc683cc50099562c91d + React-cxxreact: 724210b64158d97f150d8d254a7319e73ef77ee7 + React-debug: c01d176522cf57cdc4a4a66d1974968fcf497f32 + React-defaultsnativemodule: 3953ff49013fa997e72586628e1d218fdaf3abdb + React-domnativemodule: 540b9c7a8f31b6f4ed449aafd3a272e1f1107089 + React-Fabric: 00b792be016edad758a63c4ebac15e01d35f6355 + React-FabricComponents: 16ebdb9245d91ec27985a038d0a6460f499db54e + React-FabricImage: 2a967b5f0293c1c49ec883babfd4992d161e3583 + React-featureflags: 4150b4ddac8210b1e3c538cfb455050b5ee05d8d + React-featureflagsnativemodule: ff977040205b96818ac1f884846493cb8a2aca28 + React-graphics: ec689ac1c13a9ddb1af83baf195264676ecdbeb6 + React-hermes: ff60a3407f27f3fc82f661774a7ab6559a24ab69 + React-idlecallbacksnativemodule: 5f5ce3c424941f77da4ac3adba681149e68b1221 + React-ImageManager: 8d87296a86f9ee290c1d32c68c7be1be63492467 + React-jserrorhandler: 072756f12136284c86e96c33cdfece4d7286a99f + React-jsi: b507852b42a9125dffbf6ae7a33792fb521b29a2 + React-jsiexecutor: f970eed6debb91fe5d5d6cb5734d39cf86c59896 + React-jsinspector: 766e113e9482b22971b30236d10c04d8af38269e + React-jsinspectorcdp: 5b60350e29fe2566d9ed9799858c04b8e6095a3e + React-jsinspectornetwork: b3cc9a20c6b270f792eaaaa14313019a031b327d + React-jsinspectortracing: d99120fcf0864209c45cefbc9fc4605c8189c0ef + React-jsitooling: 9e41724cc47feadefbede31ca91d70f6ff079656 + React-jsitracing: ca020d934502de8e02cccf451501434a5e584027 + React-logger: 7b234de35acb469ce76d6bbb0457f664d6f32f62 + React-Mapbuffer: fbe1da882a187e5898bdf125e1cc6e603d27ecae + React-microtasksnativemodule: 76905804171d8ccbe69329fc84c57eb7934add7f + react-native-keyboard-controller: ab4d344c11f302965421ebdfb77fe687feaa8f7f + react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2 + React-NativeModulesApple: a9464983ccc0f66f45e93558671f60fc7536e438 + React-oscompat: 73db7dbc80edef36a9d6ed3c6c4e1724ead4236d + React-perflogger: 123272debf907cc423962adafcf4513320e43757 + React-performancetimeline: 095146e4dc8fa4568e44d7a9debc134f27e103f9 + React-RCTActionSheet: 9fc2a0901af63cefe09c8df95a08c2cf8bb7797b + React-RCTAnimation: 785e743e489bc7aec14415dbc15f4f275b2c0276 + React-RCTAppDelegate: 0602c9e13130edcde4661ea66d11122a3a66f11a + React-RCTBlob: ae53b7508a5ced43378de2a88816f63423df1f24 + React-RCTFabric: 687a0cfb5726adea7fac63560b04410c86d97134 + React-RCTFBReactNativeSpec: 7c55cf4fb4d2baad32ce3850b8504a6ee22e11ce + React-RCTImage: f45474c75cdf1526114f75b27e86d004aa171b90 + React-RCTLinking: 56622ff97570e15e01dd9b5a657010c756a9e2d8 + React-RCTNetwork: 3fffa1ab5d6981f839e7679d56f8cb731ba92c07 + React-RCTRuntime: f38c04f744596fc8e1b4c5f6a57fc05c26955257 + React-RCTSettings: f4a8e1bd36f58ec8273c73d3deefdcf90143ac6a + React-RCTText: da852a51dd1d169b38136a4f4d1eaed35376556b + React-RCTVibration: ff92ef336e32e18efff0fa83c798a2dbbebe09bd + React-rendererconsistency: b83b300e607f4e30478a5c3365e260a760232b04 + React-renderercss: aa6a3cdd4fa4e3726123c42b49ba4dd978f81688 + React-rendererdebug: 6b12a782caf2e7e2f730434264357b7b6aed1781 + React-RuntimeApple: 8934aab108dcab957a87208fef4b6f1b3a04973a + React-RuntimeCore: 1d4345561ecc402e9e88b38e1d9b059a7a13b113 + React-runtimeexecutor: a9a059f222e4d78f45a4e92cada48a5fde989fb8 + React-RuntimeHermes: 05b955709a75038d282a9420342d7bea5857768a + React-runtimescheduler: 4ce23c9157b51101092537d4171ea4de48a5b863 + React-timing: 62441edf291b91ab5b96ab8f2f8fb648c063ce6f + React-utils: 485abe7eaefa04b20e0ef442593e022563a1419b + ReactAppDependencyProvider: 433ddfb4536948630aadd5bd925aff8a632d2fe3 + ReactCodegen: 4c36f8284acb0f25e87577417039e8f3257241c6 + ReactCommon: 149b6c05126f2e99f2ed0d3c63539369546f8cae + ReactNativeDependencies: ed6d1e64802b150399f04f1d5728ec16b437251e + RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4 + RNGestureHandler: 2914750df066d89bf9d8f48a10ad5f0051108ac3 + RNReanimated: dc2d27e36d75620923891aa8e677840cf763e62c + RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845 + RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34 + RNWorklets: 8537bf4d20e1ebe1798b772d45129609517e4556 + SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf + SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57 + SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c + SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 + Yoga: 051f086b5ccf465ff2ed38a2cf5a558ae01aaaa1 + ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5 + +PODFILE CHECKSUM: 5b6c2ce3a51d2bb76aa04dac65a1cbcc8e14af2c + +COCOAPODS: 1.16.2 diff --git a/packages/mobile/ios/Podfile.properties.json b/packages/mobile/ios/Podfile.properties.json new file mode 100644 index 0000000..417e2e5 --- /dev/null +++ b/packages/mobile/ios/Podfile.properties.json @@ -0,0 +1,5 @@ +{ + "expo.jsEngine": "hermes", + "EX_DEV_CLIENT_NETWORK_INSPECTOR": "true", + "newArchEnabled": "true" +} diff --git a/packages/mobile/ios/Zovi.xcodeproj/project.pbxproj b/packages/mobile/ios/Zovi.xcodeproj/project.pbxproj new file mode 100644 index 0000000..762b9d2 --- /dev/null +++ b/packages/mobile/ios/Zovi.xcodeproj/project.pbxproj @@ -0,0 +1,560 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 2600D210A644F3099EBCAC60 /* libPods-Zovi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 2F35A3DE0EDD554ECC9665C9 /* libPods-Zovi.a */; }; + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; + B1BC7EA401DEA33ACBA14386 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 5E79EB555E8560CE37666A91 /* PrivacyInfo.xcprivacy */; }; + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; + E4F7C222FFFCC78DE13D287F /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5206ED93486F6EE25D62DF8 /* ExpoModulesProvider.swift */; }; + F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 01B1386BDA70C35F3BB3A395 /* Pods-Zovi.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Zovi.debug.xcconfig"; path = "Target Support Files/Pods-Zovi/Pods-Zovi.debug.xcconfig"; sourceTree = ""; }; + 13B07F961A680F5B00A75B9A /* Zovi.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Zovi.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Zovi/Images.xcassets; sourceTree = ""; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Zovi/Info.plist; sourceTree = ""; }; + 2F35A3DE0EDD554ECC9665C9 /* libPods-Zovi.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Zovi.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 5E79EB555E8560CE37666A91 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Zovi/PrivacyInfo.xcprivacy; sourceTree = ""; }; + A5206ED93486F6EE25D62DF8 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Zovi/ExpoModulesProvider.swift"; sourceTree = ""; }; + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Zovi/SplashScreen.storyboard; sourceTree = ""; }; + BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; + DE9441A6824A495DF6F5687D /* Pods-Zovi.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Zovi.release.xcconfig"; path = "Target Support Files/Pods-Zovi/Pods-Zovi.release.xcconfig"; sourceTree = ""; }; + ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Zovi/AppDelegate.swift; sourceTree = ""; }; + F11748442D0722820044C1D9 /* Zovi-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "Zovi-Bridging-Header.h"; path = "Zovi/Zovi-Bridging-Header.h"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 2600D210A644F3099EBCAC60 /* libPods-Zovi.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 13B07FAE1A68108700A75B9A /* Zovi */ = { + isa = PBXGroup; + children = ( + F11748412D0307B40044C1D9 /* AppDelegate.swift */, + F11748442D0722820044C1D9 /* Zovi-Bridging-Header.h */, + BB2F792B24A3F905000567C9 /* Supporting */, + 13B07FB51A68108700A75B9A /* Images.xcassets */, + 13B07FB61A68108700A75B9A /* Info.plist */, + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, + 5E79EB555E8560CE37666A91 /* PrivacyInfo.xcprivacy */, + ); + name = Zovi; + sourceTree = ""; + }; + 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ED297162215061F000B7C4FE /* JavaScriptCore.framework */, + 2F35A3DE0EDD554ECC9665C9 /* libPods-Zovi.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 68802507543A031D2F8E62A0 /* Pods */ = { + isa = PBXGroup; + children = ( + 01B1386BDA70C35F3BB3A395 /* Pods-Zovi.debug.xcconfig */, + DE9441A6824A495DF6F5687D /* Pods-Zovi.release.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + 832341AE1AAA6A7D00B99B32 /* Libraries */ = { + isa = PBXGroup; + children = ( + ); + name = Libraries; + sourceTree = ""; + }; + 83CBB9F61A601CBA00E9B192 = { + isa = PBXGroup; + children = ( + 13B07FAE1A68108700A75B9A /* Zovi */, + 832341AE1AAA6A7D00B99B32 /* Libraries */, + 83CBBA001A601CBA00E9B192 /* Products */, + 2D16E6871FA4F8E400B85C8A /* Frameworks */, + 68802507543A031D2F8E62A0 /* Pods */, + A06D5BE166B78DD90D60B1AC /* ExpoModulesProviders */, + ); + indentWidth = 2; + sourceTree = ""; + tabWidth = 2; + usesTabs = 0; + }; + 83CBBA001A601CBA00E9B192 /* Products */ = { + isa = PBXGroup; + children = ( + 13B07F961A680F5B00A75B9A /* Zovi.app */, + ); + name = Products; + sourceTree = ""; + }; + A06D5BE166B78DD90D60B1AC /* ExpoModulesProviders */ = { + isa = PBXGroup; + children = ( + C497067B79F68A595CE42947 /* Zovi */, + ); + name = ExpoModulesProviders; + sourceTree = ""; + }; + BB2F792B24A3F905000567C9 /* Supporting */ = { + isa = PBXGroup; + children = ( + BB2F792C24A3F905000567C9 /* Expo.plist */, + ); + name = Supporting; + path = Zovi/Supporting; + sourceTree = ""; + }; + C497067B79F68A595CE42947 /* Zovi */ = { + isa = PBXGroup; + children = ( + A5206ED93486F6EE25D62DF8 /* ExpoModulesProvider.swift */, + ); + name = Zovi; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 13B07F861A680F5B00A75B9A /* Zovi */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Zovi" */; + buildPhases = ( + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */, + C7038DBFE2B5A25EBB692046 /* [Expo] Configure project */, + 13B07F871A680F5B00A75B9A /* Sources */, + 13B07F8C1A680F5B00A75B9A /* Frameworks */, + 13B07F8E1A680F5B00A75B9A /* Resources */, + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, + 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */, + B720D6E7DDBC52B4CBBC0DB6 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Zovi; + productName = Zovi; + productReference = 13B07F961A680F5B00A75B9A /* Zovi.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 83CBB9F71A601CBA00E9B192 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1130; + TargetAttributes = { + 13B07F861A680F5B00A75B9A = { + DevelopmentTeam = 75MMB3DXA8; + LastSwiftMigration = 1250; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Zovi" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 83CBB9F61A601CBA00E9B192; + productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 13B07F861A680F5B00A75B9A /* Zovi */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 13B07F8E1A680F5B00A75B9A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */, + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, + B1BC7EA401DEA33ACBA14386 /* PrivacyInfo.xcprivacy in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(SRCROOT)/.xcode.env", + "$(SRCROOT)/.xcode.env.local", + ); + name = "Bundle React Native code and images"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n"; + }; + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Zovi-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Zovi/Pods-Zovi-resources.sh", + "${PODS_CONFIGURATION_BUILD_DIR}/EXApplication/ExpoApplication_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoApplication_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Zovi/Pods-Zovi-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + B720D6E7DDBC52B4CBBC0DB6 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Zovi/Pods-Zovi-frameworks.sh", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Zovi/Pods-Zovi-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + C7038DBFE2B5A25EBB692046 /* [Expo] Configure project */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(SRCROOT)/.xcode.env", + "$(SRCROOT)/.xcode.env.local", + "$(SRCROOT)/Zovi/Zovi.entitlements", + "$(SRCROOT)/Pods/Target Support Files/Pods-Zovi/expo-configure-project.sh", + ); + name = "[Expo] Configure project"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(SRCROOT)/Pods/Target Support Files/Pods-Zovi/ExpoModulesProvider.swift", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Zovi/expo-configure-project.sh\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 13B07F871A680F5B00A75B9A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */, + E4F7C222FFFCC78DE13D287F /* ExpoModulesProvider.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 13B07F941A680F5B00A75B9A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 01B1386BDA70C35F3BB3A395 /* Pods-Zovi.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Zovi/Zovi.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 75MMB3DXA8; + ENABLE_BITCODE = NO; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "FB_SONARKIT_ENABLED=1", + ); + INFOPLIST_FILE = Zovi/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = com.altricade.messenger; + PRODUCT_NAME = Zovi; + SWIFT_OBJC_BRIDGING_HEADER = "Zovi/Zovi-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 13B07F951A680F5B00A75B9A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = DE9441A6824A495DF6F5687D /* Pods-Zovi.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Zovi/Zovi.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 75MMB3DXA8; + INFOPLIST_FILE = Zovi/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.altricade.messenger; + PRODUCT_NAME = Zovi; + SWIFT_OBJC_BRIDGING_HEADER = "Zovi/Zovi-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; + 83CBBA201A601CBA00E9B192 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); + LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; + SWIFT_ENABLE_EXPLICIT_MODULES = NO; + USE_HERMES = true; + }; + name = Debug; + }; + 83CBBA211A601CBA00E9B192 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); + LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; + MTL_ENABLE_DEBUG_INFO = NO; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native"; + SDKROOT = iphoneos; + SWIFT_ENABLE_EXPLICIT_MODULES = NO; + USE_HERMES = true; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Zovi" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 13B07F941A680F5B00A75B9A /* Debug */, + 13B07F951A680F5B00A75B9A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Zovi" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83CBBA201A601CBA00E9B192 /* Debug */, + 83CBBA211A601CBA00E9B192 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; +} diff --git a/packages/mobile/ios/Zovi.xcodeproj/xcshareddata/xcschemes/Zovi.xcscheme b/packages/mobile/ios/Zovi.xcodeproj/xcshareddata/xcschemes/Zovi.xcscheme new file mode 100644 index 0000000..72812fd --- /dev/null +++ b/packages/mobile/ios/Zovi.xcodeproj/xcshareddata/xcschemes/Zovi.xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mobile/ios/Zovi.xcworkspace/contents.xcworkspacedata b/packages/mobile/ios/Zovi.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..c6bc93c --- /dev/null +++ b/packages/mobile/ios/Zovi.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/packages/mobile/ios/Zovi/AppDelegate.swift b/packages/mobile/ios/Zovi/AppDelegate.swift new file mode 100644 index 0000000..a7887e1 --- /dev/null +++ b/packages/mobile/ios/Zovi/AppDelegate.swift @@ -0,0 +1,70 @@ +import Expo +import React +import ReactAppDependencyProvider + +@UIApplicationMain +public class AppDelegate: ExpoAppDelegate { + var window: UIWindow? + + var reactNativeDelegate: ExpoReactNativeFactoryDelegate? + var reactNativeFactory: RCTReactNativeFactory? + + public override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + let delegate = ReactNativeDelegate() + let factory = ExpoReactNativeFactory(delegate: delegate) + delegate.dependencyProvider = RCTAppDependencyProvider() + + reactNativeDelegate = delegate + reactNativeFactory = factory + bindReactNativeFactory(factory) + +#if os(iOS) || os(tvOS) + window = UIWindow(frame: UIScreen.main.bounds) + factory.startReactNative( + withModuleName: "main", + in: window, + launchOptions: launchOptions) +#endif + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + // Linking API + public override func application( + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { + return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options) + } + + // Universal Links + public override func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void + ) -> Bool { + let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler) + return super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result + } +} + +class ReactNativeDelegate: ExpoReactNativeFactoryDelegate { + // Extension point for config-plugins + + override func sourceURL(for bridge: RCTBridge) -> URL? { + // needed to return the correct URL for expo-dev-client. + bridge.bundleURL ?? bundleURL() + } + + override func bundleURL() -> URL? { +#if DEBUG + return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry") +#else + return Bundle.main.url(forResource: "main", withExtension: "jsbundle") +#endif + } +} diff --git a/packages/mobile/ios/Zovi/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png b/packages/mobile/ios/Zovi/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png new file mode 100644 index 0000000..ac881f6 Binary files /dev/null and b/packages/mobile/ios/Zovi/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png differ diff --git a/packages/mobile/ios/Zovi/Images.xcassets/AppIcon.appiconset/Contents.json b/packages/mobile/ios/Zovi/Images.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..90d8d4c --- /dev/null +++ b/packages/mobile/ios/Zovi/Images.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images": [ + { + "filename": "App-Icon-1024x1024@1x.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "version": 1, + "author": "expo" + } +} \ No newline at end of file diff --git a/packages/mobile/ios/Zovi/Images.xcassets/Contents.json b/packages/mobile/ios/Zovi/Images.xcassets/Contents.json new file mode 100644 index 0000000..ed285c2 --- /dev/null +++ b/packages/mobile/ios/Zovi/Images.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "expo" + } +} diff --git a/packages/mobile/ios/Zovi/Images.xcassets/SplashScreenBackground.colorset/Contents.json b/packages/mobile/ios/Zovi/Images.xcassets/SplashScreenBackground.colorset/Contents.json new file mode 100644 index 0000000..15f02ab --- /dev/null +++ b/packages/mobile/ios/Zovi/Images.xcassets/SplashScreenBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "components": { + "alpha": "1.000", + "blue": "1.00000000000000", + "green": "1.00000000000000", + "red": "1.00000000000000" + }, + "color-space": "srgb" + }, + "idiom": "universal" + } + ], + "info": { + "version": 1, + "author": "expo" + } +} \ No newline at end of file diff --git a/packages/mobile/ios/Zovi/Info.plist b/packages/mobile/ios/Zovi/Info.plist new file mode 100644 index 0000000..3fd3386 --- /dev/null +++ b/packages/mobile/ios/Zovi/Info.plist @@ -0,0 +1,89 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Zovi + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 0.1.0 + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + zovi + com.altricade.messenger + + + + CFBundleVersion + 1 + LSMinimumSystemVersion + 12.0 + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsLocalNetworking + + + NSCameraUsageDescription + Zovi uses the camera for video messages. + NSFaceIDUsageDescription + Allow $(PRODUCT_NAME) to access your Face ID biometric data. + NSMicrophoneUsageDescription + Zovi uses the microphone to record voice messages. + NSPhotoLibraryUsageDescription + Zovi accesses your photos to share images and videos. + NSUserActivityTypes + + $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route + + RCTNewArchEnabled + + UILaunchStoryboardName + SplashScreen + UIRequiredDeviceCapabilities + + arm64 + + UIRequiresFullScreen + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Automatic + UIViewControllerBasedStatusBarAppearance + + + \ No newline at end of file diff --git a/packages/mobile/ios/Zovi/PrivacyInfo.xcprivacy b/packages/mobile/ios/Zovi/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..49b8ded --- /dev/null +++ b/packages/mobile/ios/Zovi/PrivacyInfo.xcprivacy @@ -0,0 +1,48 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + 0A2A.1 + 3B52.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryDiskSpace + NSPrivacyAccessedAPITypeReasons + + E174.1 + 85F4.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/packages/mobile/ios/Zovi/SplashScreen.storyboard b/packages/mobile/ios/Zovi/SplashScreen.storyboard new file mode 100644 index 0000000..6c99b2a --- /dev/null +++ b/packages/mobile/ios/Zovi/SplashScreen.storyboard @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/mobile/ios/Zovi/Supporting/Expo.plist b/packages/mobile/ios/Zovi/Supporting/Expo.plist new file mode 100644 index 0000000..750be02 --- /dev/null +++ b/packages/mobile/ios/Zovi/Supporting/Expo.plist @@ -0,0 +1,12 @@ + + + + + EXUpdatesCheckOnLaunch + ALWAYS + EXUpdatesEnabled + + EXUpdatesLaunchWaitMs + 0 + + \ No newline at end of file diff --git a/packages/mobile/ios/Zovi/Zovi-Bridging-Header.h b/packages/mobile/ios/Zovi/Zovi-Bridging-Header.h new file mode 100644 index 0000000..8361941 --- /dev/null +++ b/packages/mobile/ios/Zovi/Zovi-Bridging-Header.h @@ -0,0 +1,3 @@ +// +// Use this file to import your target's public headers that you would like to expose to Swift. +// diff --git a/packages/mobile/ios/Zovi/Zovi.entitlements b/packages/mobile/ios/Zovi/Zovi.entitlements new file mode 100644 index 0000000..018a6e2 --- /dev/null +++ b/packages/mobile/ios/Zovi/Zovi.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + \ No newline at end of file diff --git a/packages/mobile/metro.config.js b/packages/mobile/metro.config.js new file mode 100644 index 0000000..c461eaa --- /dev/null +++ b/packages/mobile/metro.config.js @@ -0,0 +1,21 @@ +// Metro tuned for the pnpm monorepo: watch the workspace root so changes in +// @altricade/core (consumed as TS source via package exports) are picked up, +// and resolve modules from both the app and the root node_modules. +const { getDefaultConfig } = require('expo/metro-config'); +const path = require('path'); + +const projectRoot = __dirname; +const workspaceRoot = path.resolve(projectRoot, '../..'); + +const config = getDefaultConfig(projectRoot); + +config.watchFolders = [workspaceRoot]; +config.resolver.nodeModulesPaths = [ + path.resolve(projectRoot, 'node_modules'), + path.resolve(workspaceRoot, 'node_modules'), +]; +// @altricade/core's package.json exports point at ./src/*.ts — honor them. +config.resolver.unstable_enablePackageExports = true; +config.resolver.unstable_conditionNames = ['react-native', 'require', 'import', 'default']; + +module.exports = config; diff --git a/packages/mobile/package.json b/packages/mobile/package.json index b562224..40fd10f 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -2,12 +2,53 @@ "name": "@altricade/mobile", "version": "0.0.0", "private": true, - "type": "module", + "main": "expo-router/entry", "scripts": { + "start": "expo start", + "android": "expo run:android", + "ios": "expo run:ios", "typecheck": "tsc --noEmit", "lint": "eslint ." }, "dependencies": { - "@altricade/core": "workspace:^" + "@altricade/core": "workspace:^", + "@legendapp/list": "^1.0.0", + "@react-native-async-storage/async-storage": "^2.1.2", + "centrifuge": "^5.3.4", + "expo": "^54.0.0", + "expo-audio": "^1.0.0", + "expo-camera": "^17.0.0", + "expo-clipboard": "^8.0.0", + "expo-constants": "^18.0.0", + "expo-crypto": "^15.0.0", + "expo-document-picker": "^14.0.0", + "expo-file-system": "^19.0.0", + "expo-haptics": "^15.0.0", + "expo-image": "^3.0.0", + "expo-image-picker": "^17.0.0", + "expo-linking": "^8.0.0", + "expo-notifications": "^0.32.0", + "expo-router": "^6.0.0", + "expo-secure-store": "^15.0.0", + "expo-splash-screen": "^31.0.0", + "expo-status-bar": "^3.0.0", + "expo-system-ui": "^6.0.0", + "expo-video": "^3.0.0", + "expo-video-thumbnails": "~10.0.8", + "react": "19.1.0", + "react-native": "0.81.4", + "react-native-compressor": "^2.0.2", + "react-native-gesture-handler": "~2.28.0", + "react-native-keyboard-controller": "^1.22.0", + "react-native-reanimated": "~4.1.0", + "react-native-safe-area-context": "~5.6.0", + "react-native-screens": "~4.16.0", + "react-native-svg": "15.12.1", + "react-native-worklets": "0.5.1", + "zustand": "^5.0.8" + }, + "devDependencies": { + "@types/react": "~19.1.0", + "typescript": "5.9.2" } } diff --git a/packages/mobile/src/api/config.ts b/packages/mobile/src/api/config.ts new file mode 100644 index 0000000..1c3027a --- /dev/null +++ b/packages/mobile/src/api/config.ts @@ -0,0 +1,34 @@ +import Constants from 'expo-constants'; +import type { ApiClientConfig } from '@altricade/core/api'; +import { getAccessToken, getRefreshToken } from './token-store'; + +interface Extra { + apiUrl: string; + wsUrl: string; +} + +// Read public config from app.config.ts `extra` (EXPO_PUBLIC_* driven). +const extra: Extra = ((): Extra => { + const value: unknown = Constants.expoConfig?.extra; + if (typeof value === 'object' && value !== null && 'apiUrl' in value && 'wsUrl' in value) { + const apiUrl: unknown = value.apiUrl; + const wsUrl: unknown = value.wsUrl; + if (typeof apiUrl === 'string' && typeof wsUrl === 'string') { + return { apiUrl, wsUrl }; + } + } + return { apiUrl: 'http://localhost:8080/api', wsUrl: 'ws://localhost:8080/connection/websocket' }; +})(); + +export const apiBaseUrl = extra.apiUrl; +export const wsUrl = extra.wsUrl; + +// The shared API client, configured for native token transport: the refresh +// token is stored in the device keychain and sent explicitly (cookies are +// unreliable in React Native), signalled to the backend via X-Auth-Mode: token. +export const apiConfig: ApiClientConfig = { + baseUrl: apiBaseUrl, + authMode: 'token', + getAccessToken, + getRefreshToken, +}; diff --git a/packages/mobile/src/api/index.ts b/packages/mobile/src/api/index.ts new file mode 100644 index 0000000..8647b62 --- /dev/null +++ b/packages/mobile/src/api/index.ts @@ -0,0 +1,9 @@ +export { apiConfig, apiBaseUrl, wsUrl } from './config'; +export { + getAccessToken, + setAccessToken, + getRefreshToken, + setRefreshToken, + loadRefreshToken, + clearTokens, +} from './token-store'; diff --git a/packages/mobile/src/api/token-store.ts b/packages/mobile/src/api/token-store.ts new file mode 100644 index 0000000..92f21f4 --- /dev/null +++ b/packages/mobile/src/api/token-store.ts @@ -0,0 +1,38 @@ +import * as SecureStore from 'expo-secure-store'; + +// Token storage for native: the access token lives only in memory (short-lived, +// re-obtained via refresh on launch); the refresh token persists in the device +// keychain/keystore — never AsyncStorage, never plain files. + +const REFRESH_KEY = 'zovi.refreshToken'; + +let accessToken: string | null = null; +let refreshToken: string | null = null; + +export const getAccessToken = (): string | null => accessToken; + +export const setAccessToken = (token: string | null): void => { + accessToken = token; +}; + +export const getRefreshToken = (): string | null => refreshToken; + +// Load the persisted refresh token into memory (call once at launch). +export const loadRefreshToken = async (): Promise => { + refreshToken = await SecureStore.getItemAsync(REFRESH_KEY); + return refreshToken; +}; + +export const setRefreshToken = async (token: string | null): Promise => { + refreshToken = token; + if (token === null) { + await SecureStore.deleteItemAsync(REFRESH_KEY); + } else { + await SecureStore.setItemAsync(REFRESH_KEY, token); + } +}; + +export const clearTokens = async (): Promise => { + accessToken = null; + await setRefreshToken(null); +}; diff --git a/packages/mobile/src/components/ActionSheet.tsx b/packages/mobile/src/components/ActionSheet.tsx new file mode 100644 index 0000000..096b592 --- /dev/null +++ b/packages/mobile/src/components/ActionSheet.tsx @@ -0,0 +1,104 @@ +import type { ReactElement } from 'react'; +import { Modal, Pressable, StyleSheet, Text } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useTheme } from '@/theme'; +import { spacing, radius, fontSize } from '@/theme'; +import { Icon } from './Icon'; +import type { IconName } from './Icon'; + +export interface SheetAction { + key: string; + label: string; + icon?: IconName; + destructive?: boolean; + onPress: () => void; +} + +interface Props { + visible: boolean; + title?: string | undefined; + actions: SheetAction[]; + onClose: () => void; + /** Optional custom header row (e.g. a reactions strip). */ + header?: ReactElement; +} + +// Bottom action sheet (the mobile equivalent of the web right-click menu). +export const ActionSheet = ({ visible, title, actions, onClose, header }: Props): ReactElement => { + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + + return ( + + + { + event.stopPropagation(); + }} + > + {header} + {title !== undefined ? ( + {title} + ) : null} + {actions.map((action) => ( + [styles.item, pressed && { backgroundColor: colors.surfaceHover }]} + onPress={() => { + action.onPress(); + onClose(); + }} + > + {action.icon !== undefined ? ( + + ) : null} + + {action.label} + + + ))} + + + + ); +}; + +const styles = StyleSheet.create({ + backdrop: { flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.45)' }, + sheet: { + borderTopLeftRadius: radius.xl, + borderTopRightRadius: radius.xl, + paddingTop: spacing.sm, + paddingHorizontal: spacing.sm, + }, + title: { + fontSize: fontSize.xs, + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: 0.6, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + }, + item: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingHorizontal: spacing.md, + paddingVertical: spacing.md, + borderRadius: radius.md, + }, + label: { fontSize: fontSize.md }, +}); diff --git a/packages/mobile/src/components/Avatar.tsx b/packages/mobile/src/components/Avatar.tsx new file mode 100644 index 0000000..344f169 --- /dev/null +++ b/packages/mobile/src/components/Avatar.tsx @@ -0,0 +1,81 @@ +import type { ReactElement } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { Image } from 'expo-image'; +import { useTheme } from '@/theme'; +import { Icon } from './Icon'; +import type { IconName } from './Icon'; + +interface Props { + uri?: string | null | undefined; + /** Fallback initial letter when there is no image and no icon. */ + name?: string | undefined; + /** Fallback icon (group/channel) when there is no image. */ + icon?: IconName | undefined; + size?: number; + online?: boolean; +} + +// Round avatar: network image via expo-image (cached), else an accent gradient +// placeholder with the initial letter or a kind icon. Optional presence dot. +export const Avatar = ({ uri, name, icon, size = 44, online = false }: Props): ReactElement => { + const { colors } = useTheme(); + const dot = Math.max(10, Math.round(size * 0.28)); + + return ( + + {uri !== undefined && uri !== null ? ( + + ) : ( + + {icon !== undefined ? ( + + ) : ( + + {(name ?? '?').charAt(0).toUpperCase()} + + )} + + )} + {online ? ( + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + placeholder: { + alignItems: 'center', + justifyContent: 'center', + }, + initial: { + fontWeight: '700', + }, + dot: { + position: 'absolute', + right: -1, + bottom: -1, + borderWidth: 2.5, + }, +}); diff --git a/packages/mobile/src/components/ErrorBoundary.tsx b/packages/mobile/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..9577664 --- /dev/null +++ b/packages/mobile/src/components/ErrorBoundary.tsx @@ -0,0 +1,54 @@ +import { Component } from 'react'; +import type { ErrorInfo, ReactNode } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +interface Props { + children: ReactNode; +} + +interface State { + error: Error | null; +} + +// Top-level safety net: a render crash anywhere below shows a recoverable +// screen instead of a white/dead app. Wire crash reporting (e.g. Sentry) in +// componentDidCatch before the first production release. +export class ErrorBoundary extends Component { + override state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + override componentDidCatch(error: Error, info: ErrorInfo): void { + + console.error('Unhandled error:', error, info.componentStack); + } + + reset = (): void => { + this.setState({ error: null }); + }; + + override render(): ReactNode { + if (this.state.error !== null) { + return ( + + Something went wrong + {this.state.error.message} + + Try again + + + ); + } + return this.props.children; + } +} + +const styles = StyleSheet.create({ + root: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32, gap: 12, backgroundColor: '#0e0f13' }, + title: { fontSize: 20, fontWeight: '700', color: '#f3f4f7' }, + message: { fontSize: 14, color: '#98a1b2', textAlign: 'center' }, + button: { marginTop: 12, paddingHorizontal: 24, paddingVertical: 12, borderRadius: 999, backgroundColor: '#5b7cff' }, + buttonText: { color: '#fff', fontWeight: '600', fontSize: 15 }, +}); diff --git a/packages/mobile/src/components/Icon.tsx b/packages/mobile/src/components/Icon.tsx new file mode 100644 index 0000000..eb845ef --- /dev/null +++ b/packages/mobile/src/components/Icon.tsx @@ -0,0 +1,250 @@ +import type { ReactElement, ReactNode } from 'react'; +import Svg, { Path, Circle, Line, Polyline } from 'react-native-svg'; + +// Inline SVG icon set (mirrors web/src/shared/ui/icons.tsx) so glyphs render +// identically on iOS/Android — no emoji font variance. `color` drives stroke. +export type IconName = + | 'send' + | 'mic' + | 'camera' + | 'paperclip' + | 'image' + | 'play' + | 'pause' + | 'stop' + | 'check' + | 'doubleCheck' + | 'close' + | 'chevronLeft' + | 'chevronRight' + | 'chevronDown' + | 'search' + | 'plus' + | 'users' + | 'megaphone' + | 'settings' + | 'user' + | 'trash' + | 'edit' + | 'reply' + | 'forward' + | 'pin' + | 'pinOff' + | 'copy' + | 'download' + | 'file' + | 'moon' + | 'sun' + | 'monitor' + | 'logout' + | 'more' + | 'chats' + | 'phone' + | 'refresh'; + +interface Props { + name: IconName; + size?: number; + color: string; +} + +const STROKE: Record = { + send: , + mic: ( + <> + + + + + + ), + camera: ( + <> + + + + ), + paperclip: ( + + ), + image: ( + <> + + + + + ), + play: , + pause: ( + <> + + + + ), + stop: , + check: , + doubleCheck: ( + <> + + + + ), + close: ( + <> + + + + ), + refresh: ( + <> + + + + ), + chevronLeft: , + chevronRight: , + chevronDown: , + search: ( + <> + + + + ), + plus: ( + <> + + + + ), + users: ( + <> + + + + + ), + megaphone: ( + <> + + + + ), + settings: ( + <> + + + + ), + user: ( + <> + + + + ), + trash: ( + <> + + + + ), + edit: ( + <> + + + + ), + reply: ( + <> + + + + ), + forward: ( + <> + + + + ), + pin: ( + <> + + + + ), + pinOff: ( + <> + + + + + ), + copy: ( + <> + + + + ), + download: ( + <> + + + + + ), + file: ( + <> + + + + ), + moon: , + sun: ( + <> + + + + ), + monitor: ( + <> + + + + + ), + logout: ( + <> + + + + + ), + more: ( + <> + + + + + ), + chats: ( + + ), + phone: ( + + ), +}; + +export const Icon = ({ name, size = 22, color }: Props): ReactElement => ( + + {STROKE[name]} + +); diff --git a/packages/mobile/src/components/IconButton.tsx b/packages/mobile/src/components/IconButton.tsx new file mode 100644 index 0000000..f355156 --- /dev/null +++ b/packages/mobile/src/components/IconButton.tsx @@ -0,0 +1,59 @@ +import type { ReactElement } from 'react'; +import { Pressable, StyleSheet } from 'react-native'; +import * as Haptics from 'expo-haptics'; +import { useTheme } from '@/theme'; +import { Icon } from './Icon'; +import type { IconName } from './Icon'; + +interface Props { + name: IconName; + onPress: () => void; + size?: number; + color?: string; + accent?: boolean; + haptic?: boolean; + accessibilityLabel: string; +} + +// 44pt touch target icon button (iOS min), optional accent fill + haptic tick. +export const IconButton = ({ + name, + onPress, + size = 22, + color, + accent = false, + haptic = false, + accessibilityLabel, +}: Props): ReactElement => { + const { colors } = useTheme(); + return ( + { + if (haptic) { + void Haptics.selectionAsync(); + } + onPress(); + }} + style={({ pressed }) => [ + styles.button, + accent && { backgroundColor: colors.accent }, + pressed && { opacity: 0.6 }, + ]} + > + + + ); +}; + +const styles = StyleSheet.create({ + button: { + width: 44, + height: 44, + borderRadius: 22, + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/packages/mobile/src/components/Screen.tsx b/packages/mobile/src/components/Screen.tsx new file mode 100644 index 0000000..364ab12 --- /dev/null +++ b/packages/mobile/src/components/Screen.tsx @@ -0,0 +1,35 @@ +import type { ReactElement, ReactNode } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useTheme } from '@/theme'; + +interface Props { + children: ReactNode; + /** Which safe-area edges to pad. Defaults to top only (headers own the top). */ + edges?: { top?: boolean; bottom?: boolean }; + backgroundColor?: string; +} + +// Themed full-screen container that respects safe-area insets per edge. +export const Screen = ({ children, edges, backgroundColor }: Props): ReactElement => { + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + return ( + + {children} + + ); +}; + +const styles = StyleSheet.create({ + root: { flex: 1 }, +}); diff --git a/packages/mobile/src/components/ScreenHeader.tsx b/packages/mobile/src/components/ScreenHeader.tsx new file mode 100644 index 0000000..b1a1bac --- /dev/null +++ b/packages/mobile/src/components/ScreenHeader.tsx @@ -0,0 +1,56 @@ +import type { ReactElement, ReactNode } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { useRouter } from 'expo-router'; +import { IconButton } from './IconButton'; +import { useTheme } from '@/theme'; +import { spacing, fontSize } from '@/theme'; + +interface Props { + title: string; + /** Optional trailing controls (e.g. a save/create button). */ + right?: ReactNode; + onBack?: () => void; + /** Tab roots have nowhere to go back to — hide the chevron there. */ + showBack?: boolean; +} + +// Standard back + title bar for secondary screens. +export const ScreenHeader = ({ title, right, onBack, showBack = true }: Props): ReactElement => { + const { colors } = useTheme(); + const router = useRouter(); + return ( + + {showBack ? ( + { router.back(); })} + accessibilityLabel="Back" + /> + ) : null} + + {title} + + {right} + + ); +}; + +const styles = StyleSheet.create({ + bar: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + barNoBack: { paddingLeft: spacing.lg }, + title: { flex: 1, fontSize: fontSize.lg, fontWeight: '700' }, + right: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs }, +}); diff --git a/packages/mobile/src/components/UserRow.tsx b/packages/mobile/src/components/UserRow.tsx new file mode 100644 index 0000000..8d43432 --- /dev/null +++ b/packages/mobile/src/components/UserRow.tsx @@ -0,0 +1,56 @@ +import type { ReactElement } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import type { PublicUser } from '@altricade/core'; +import { Avatar } from './Avatar'; +import { Icon } from './Icon'; +import { useTheme } from '@/theme'; +import { spacing, fontSize } from '@/theme'; + +interface Props { + user: PublicUser; + subtitle?: string | undefined; + selected?: boolean | undefined; + onPress: (user: PublicUser) => void; +} + +// Reusable person row for contacts, search results, member lists, and pickers. +export const UserRow = ({ user, subtitle, selected, onPress }: Props): ReactElement => { + const { colors } = useTheme(); + return ( + { + onPress(user); + }} + style={({ pressed }) => [styles.row, pressed && { backgroundColor: colors.surfaceHover }]} + > + + + + {user.displayName} + + + {subtitle ?? `@${user.username}`} + + + {selected === true ? ( + + + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + }, + body: { flex: 1, gap: 2 }, + name: { fontSize: fontSize.md, fontWeight: '600' }, + sub: { fontSize: fontSize.sm }, + check: { width: 24, height: 24, borderRadius: 12, alignItems: 'center', justifyContent: 'center' }, +}); diff --git a/packages/mobile/src/components/index.ts b/packages/mobile/src/components/index.ts new file mode 100644 index 0000000..f511155 --- /dev/null +++ b/packages/mobile/src/components/index.ts @@ -0,0 +1,10 @@ +export { Icon } from './Icon'; +export type { IconName } from './Icon'; +export { Avatar } from './Avatar'; +export { IconButton } from './IconButton'; +export { Screen } from './Screen'; +export { ActionSheet } from './ActionSheet'; +export type { SheetAction } from './ActionSheet'; +export { UserRow } from './UserRow'; +export { ScreenHeader } from './ScreenHeader'; +export { ErrorBoundary } from './ErrorBoundary'; diff --git a/packages/mobile/src/features/auth/SignInScreen.tsx b/packages/mobile/src/features/auth/SignInScreen.tsx new file mode 100644 index 0000000..dd9b475 --- /dev/null +++ b/packages/mobile/src/features/auth/SignInScreen.tsx @@ -0,0 +1,157 @@ +import { useState } from 'react'; +import type { ReactElement } from 'react'; +import { + ActivityIndicator, + KeyboardAvoidingView, + Platform, + Pressable, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { ApiError } from '@altricade/core/api'; +import { useSession } from '@/stores/session'; +import { Screen } from '@/components'; +import { useTheme } from '@/theme'; +import { spacing, radius, fontSize } from '@/theme'; + +type Mode = 'login' | 'register'; + +export const SignInScreen = (): ReactElement => { + const { colors } = useTheme(); + const login = useSession((s) => s.login); + const register = useSession((s) => s.register); + + const [mode, setMode] = useState('login'); + const [username, setUsername] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const submit = (): void => { + setBusy(true); + setError(null); + void (async () => { + try { + if (mode === 'login') { + await login({ username: username.trim(), password }); + } else { + await register({ username: username.trim(), displayName: displayName.trim(), password }); + } + } catch (caught) { + setError(caught instanceof ApiError ? caught.message : 'Something went wrong'); + setBusy(false); + } + })(); + }; + + const inputStyle = [styles.input, { backgroundColor: colors.surface, color: colors.text }]; + + return ( + + + + + Z + + Zovi + + {mode === 'login' ? 'Welcome back.' : 'Create your account.'} + + + + {mode === 'register' ? ( + + ) : null} + + + {error !== null ? {error} : null} + + + {busy ? ( + + ) : ( + + {mode === 'login' ? 'Log in' : 'Create account'} + + )} + + + { + setMode(mode === 'login' ? 'register' : 'login'); + setError(null); + }} + > + + {mode === 'login' ? 'Need an account? Sign up' : 'Have an account? Log in'} + + + + + + ); +}; + +const styles = StyleSheet.create({ + flex: { flex: 1 }, + center: { flex: 1, justifyContent: 'center', paddingHorizontal: spacing.xl }, + mark: { + width: 56, + height: 56, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + alignSelf: 'center', + marginBottom: spacing.md, + }, + markText: { fontSize: 28, fontWeight: '800' }, + title: { fontSize: 28, fontWeight: '700', textAlign: 'center' }, + tagline: { textAlign: 'center', marginTop: spacing.xs, marginBottom: spacing.xl }, + input: { + borderRadius: radius.md, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + fontSize: fontSize.base, + marginBottom: spacing.md, + }, + error: { fontSize: fontSize.sm, marginBottom: spacing.sm }, + primary: { + borderRadius: radius.md, + paddingVertical: spacing.md, + alignItems: 'center', + marginTop: spacing.xs, + }, + primaryText: { fontSize: fontSize.md, fontWeight: '600' }, + switch: { textAlign: 'center', marginTop: spacing.xl, fontWeight: '600' }, +}); diff --git a/packages/mobile/src/features/auth/index.ts b/packages/mobile/src/features/auth/index.ts new file mode 100644 index 0000000..387e71e --- /dev/null +++ b/packages/mobile/src/features/auth/index.ts @@ -0,0 +1 @@ +export { SignInScreen } from './SignInScreen'; diff --git a/packages/mobile/src/features/contacts/ContactsScreen.tsx b/packages/mobile/src/features/contacts/ContactsScreen.tsx new file mode 100644 index 0000000..75e6c40 --- /dev/null +++ b/packages/mobile/src/features/contacts/ContactsScreen.tsx @@ -0,0 +1,102 @@ +import { useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import { ActivityIndicator, StyleSheet, Text, TextInput, View } from 'react-native'; +import { LegendList } from '@legendapp/list'; +import { useRouter } from 'expo-router'; +import type { PublicUser } from '@altricade/core'; +import { listContacts, searchUsers, createDirect } from '@altricade/core/api'; +import { apiConfig } from '@/api'; +import { Screen, ScreenHeader, UserRow } from '@/components'; +import { useTheme } from '@/theme'; +import { spacing, radius, fontSize } from '@/theme'; + +export const ContactsScreen = (): ReactElement => { + const { colors } = useTheme(); + const router = useRouter(); + const [query, setQuery] = useState(''); + const [contacts, setContacts] = useState([]); + const [results, setResults] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + void listContacts(apiConfig).then((list) => { + setContacts(list.map((c) => c.user)); + }); + }, []); + + useEffect(() => { + const term = query.trim(); + if (term.length < 2) { + setResults(null); + return; + } + let cancelled = false; + const timer = setTimeout(() => { + void searchUsers(apiConfig, term).then((users) => { + if (!cancelled) { + setResults(users); + } + }); + }, 280); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [query]); + + const open = (user: PublicUser): void => { + if (busy) { + return; + } + setBusy(true); + void createDirect(apiConfig, { username: user.username }) + .then((conversation) => { + router.replace(`/chat/${conversation.id}`); + }) + .finally(() => { + setBusy(false); + }); + }; + + const data = results ?? contacts; + + return ( + + + + + + {busy ? : null} + item.id} + estimatedItemSize={62} + renderItem={({ item }) => } + ListEmptyComponent={ + + {results === null ? 'No contacts yet' : 'No people found'} + + } + /> + + ); +}; + +const styles = StyleSheet.create({ + searchWrap: { paddingHorizontal: spacing.lg, paddingVertical: spacing.sm }, + search: { + borderRadius: radius.lg, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + fontSize: fontSize.md, + }, + busy: { marginVertical: spacing.sm }, + empty: { textAlign: 'center', marginTop: spacing.xl, fontSize: fontSize.base }, +}); diff --git a/packages/mobile/src/features/contacts/index.ts b/packages/mobile/src/features/contacts/index.ts new file mode 100644 index 0000000..a8b8a87 --- /dev/null +++ b/packages/mobile/src/features/contacts/index.ts @@ -0,0 +1 @@ +export { ContactsScreen } from './ContactsScreen'; diff --git a/packages/mobile/src/features/conversations/ChatsScreen.tsx b/packages/mobile/src/features/conversations/ChatsScreen.tsx new file mode 100644 index 0000000..c27a4c0 --- /dev/null +++ b/packages/mobile/src/features/conversations/ChatsScreen.tsx @@ -0,0 +1,118 @@ +import { useState } from 'react'; +import type { ReactElement } from 'react'; +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'; +import { LegendList } from '@legendapp/list'; +import { useRouter } from 'expo-router'; +import type { Conversation } from '@altricade/core'; +import { useSession } from '@/stores/session'; +import { Avatar, Icon, IconButton, Screen } from '@/components'; +import { ActionSheet } from '@/components'; +import type { SheetAction } from '@/components'; +import { useTheme } from '@/theme'; +import { spacing, fontSize } from '@/theme'; +import { useConversations } from './model'; +import { ConversationRow } from './ConversationRow'; + +export const ChatsScreen = (): ReactElement => { + const { colors } = useTheme(); + const router = useRouter(); + const user = useSession((s) => s.user); + const meId = user?.id ?? ''; + const { conversations, loading, onlineMap, clearChat, deleteChat } = useConversations(meId); + + const [compose, setCompose] = useState(false); + const [rowMenu, setRowMenu] = useState(null); + + const composeActions: SheetAction[] = [ + { key: 'group', label: 'New Group', icon: 'users', onPress: () => { router.push('/new/group'); } }, + { key: 'channel', label: 'New Channel', icon: 'megaphone', onPress: () => { router.push('/new/channel'); } }, + { key: 'contacts', label: 'Find people', icon: 'search', onPress: () => { router.push('/contacts'); } }, + ]; + + const rowActions = (conversation: Conversation): SheetAction[] => [ + { + key: 'clear', + label: 'Clear history', + icon: 'trash', + onPress: () => { + void clearChat(conversation.id); + }, + }, + { + key: 'delete', + label: 'Delete chat', + icon: 'trash', + destructive: true, + onPress: () => { + void deleteChat(conversation.id); + }, + }, + ]; + + return ( + + + Zovi + + { router.push('/contacts'); }} accessibilityLabel="Search" /> + { setCompose(true); }} accessibilityLabel="New chat" /> + { router.push('/settings'); }} hitSlop={6}> + + + + + + {loading ? ( + + + + ) : conversations.length === 0 ? ( + + + No conversations yet + + ) : ( + item.id} + estimatedItemSize={68} + renderItem={({ item }) => ( + { router.push(`/chat/${c.id}`); }} + onLongPress={setRowMenu} + /> + )} + /> + )} + + { setCompose(false); }} /> + { setRowMenu(null); }} + /> + + ); +}; + +const title = (c: Conversation): string => + c.type === 'direct' ? (c.peer?.displayName ?? 'Direct') : (c.title ?? 'Chat'); + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + brand: { fontSize: fontSize.xl, fontWeight: '700' }, + headerActions: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs }, + center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.md }, + empty: { fontSize: fontSize.base }, +}); diff --git a/packages/mobile/src/features/conversations/ConversationRow.tsx b/packages/mobile/src/features/conversations/ConversationRow.tsx new file mode 100644 index 0000000..5b887c4 --- /dev/null +++ b/packages/mobile/src/features/conversations/ConversationRow.tsx @@ -0,0 +1,139 @@ +import { memo } from 'react'; +import type { ReactElement } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import type { Conversation, MediaKind } from '@altricade/core'; +import { Avatar } from '@/components'; +import { useTheme } from '@/theme'; +import { spacing, fontSize } from '@/theme'; + +interface Props { + conversation: Conversation; + meId: string; + online: boolean; + onPress: (conversation: Conversation) => void; + onLongPress: (conversation: Conversation) => void; +} + +const mediaLabel = (kind: MediaKind): string => { + switch (kind) { + case 'image': + return 'Photo'; + case 'video': + return 'Video'; + case 'video_note': + return 'Video message'; + case 'voice': + return 'Voice message'; + default: + return 'File'; + } +}; + +const title = (c: Conversation): string => { + if (c.type !== 'direct') { + return c.title ?? (c.type === 'channel' ? 'Channel' : 'Group'); + } + return c.peer?.displayName ?? 'Direct'; +}; + +const subtitle = (c: Conversation, meId: string): string => { + const last = c.lastMessage; + if (last === null) { + return c.type === 'channel' ? 'Channel' : c.type === 'group' ? 'Group' : ''; + } + const body = last.deleted + ? 'Message deleted' + : last.content.length > 0 + ? last.content + : last.mediaKind !== null + ? mediaLabel(last.mediaKind) + : ''; + const prefix = last.senderId === meId ? 'You: ' : c.type === 'group' ? `${last.senderName}: ` : ''; + return `${prefix}${body}`; +}; + +const formatTime = (iso: string): string => { + const then = new Date(iso); + const now = new Date(); + if (then.toDateString() === now.toDateString()) { + return then.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + if (now.getTime() - then.getTime() < 6 * 86_400_000) { + return then.toLocaleDateString([], { weekday: 'short' }); + } + return then.toLocaleDateString([], { day: '2-digit', month: '2-digit' }); +}; + +const ConversationRowInner = ({ conversation, meId, online, onPress, onLongPress }: Props): ReactElement => { + const { colors } = useTheme(); + const icon = conversation.type === 'channel' ? 'megaphone' : conversation.type === 'group' ? 'users' : undefined; + + return ( + { + onPress(conversation); + }} + onLongPress={() => { + onLongPress(conversation); + }} + style={({ pressed }) => [styles.row, pressed && { backgroundColor: colors.surfaceHover }]} + > + + + + + {title(conversation)} + + + {formatTime(conversation.lastMessageAt)} + + + + + {subtitle(conversation, meId)} + + {conversation.unreadCount > 0 ? ( + + + {conversation.unreadCount} + + + ) : null} + + + + ); +}; + +export const ConversationRow = memo(ConversationRowInner); + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + gap: spacing.md, + }, + main: { flex: 1, gap: 3 }, + topRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing.sm }, + name: { flex: 1, fontSize: fontSize.md, fontWeight: '600' }, + time: { fontSize: fontSize.xs }, + bottomRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing.sm }, + preview: { flex: 1, fontSize: fontSize.sm }, + badge: { + minWidth: 22, + height: 22, + borderRadius: 11, + paddingHorizontal: 6, + alignItems: 'center', + justifyContent: 'center', + }, + badgeText: { fontSize: fontSize.xs, fontWeight: '700' }, +}); diff --git a/packages/mobile/src/features/conversations/NewChatWizard.tsx b/packages/mobile/src/features/conversations/NewChatWizard.tsx new file mode 100644 index 0000000..3f0d641 --- /dev/null +++ b/packages/mobile/src/features/conversations/NewChatWizard.tsx @@ -0,0 +1,170 @@ +import { useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; +import { useRouter } from 'expo-router'; +import type { PublicUser } from '@altricade/core'; +import { searchUsers, createGroup, createChannel } from '@altricade/core/api'; +import { apiConfig } from '@/api'; +import { Icon, Screen, ScreenHeader, UserRow } from '@/components'; +import { useTheme } from '@/theme'; +import { spacing, radius, fontSize } from '@/theme'; + +interface Props { + mode: 'group' | 'channel'; +} + +export const NewChatWizard = ({ mode }: Props): ReactElement => { + const { colors } = useTheme(); + const router = useRouter(); + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [selected, setSelected] = useState([]); + const [busy, setBusy] = useState(false); + + useEffect(() => { + const term = query.trim(); + if (term.length < 2) { + setResults([]); + return; + } + let cancelled = false; + const timer = setTimeout(() => { + void searchUsers(apiConfig, term).then((users) => { + if (!cancelled) { + setResults(users); + } + }); + }, 280); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [query]); + + const toggle = (user: PublicUser): void => { + setSelected((prev) => + prev.some((u) => u.id === user.id) ? prev.filter((u) => u.id !== user.id) : [...prev, user], + ); + }; + + const create = (): void => { + const name = title.trim(); + if (name.length === 0 || busy) { + return; + } + setBusy(true); + const members = selected.map((u) => u.id); + const request = + mode === 'group' + ? createGroup(apiConfig, { title: name, members }) + : createChannel(apiConfig, { + title: name, + members, + ...(description.trim().length > 0 ? { description: description.trim() } : {}), + }); + void request + .then((conversation) => { + router.replace(`/chat/${conversation.id}`); + }) + .finally(() => { + setBusy(false); + }); + }; + + const createButton = ( + + + Create + + + ); + + return ( + + + + + {mode === 'channel' ? ( + + ) : null} + + {selected.length > 0 ? ( + + {selected.map((user) => ( + { + toggle(user); + }} + style={[styles.chip, { backgroundColor: colors.accentSoft }]} + > + {user.displayName} + + + ))} + + ) : null} + + + {results.map((user) => ( + u.id === user.id)} + onPress={toggle} + /> + ))} + + + ); +}; + +const styles = StyleSheet.create({ + field: { + borderRadius: radius.lg, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + fontSize: fontSize.md, + marginHorizontal: spacing.lg, + marginTop: spacing.md, + }, + multiline: { minHeight: 72, textAlignVertical: 'top' }, + create: { fontSize: fontSize.md, fontWeight: '700', paddingHorizontal: spacing.sm }, + chips: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs, paddingHorizontal: spacing.lg, marginTop: spacing.md }, + chip: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.md, + paddingVertical: 6, + borderRadius: radius.full, + }, + chipText: { fontSize: fontSize.sm, fontWeight: '600' }, +}); diff --git a/packages/mobile/src/features/conversations/index.ts b/packages/mobile/src/features/conversations/index.ts new file mode 100644 index 0000000..ab80f67 --- /dev/null +++ b/packages/mobile/src/features/conversations/index.ts @@ -0,0 +1,5 @@ +export { ChatsScreen } from './ChatsScreen'; +export { ConversationRow } from './ConversationRow'; +export { NewChatWizard } from './NewChatWizard'; +export { useConversations } from './model'; +export type { UseConversations } from './model'; diff --git a/packages/mobile/src/features/conversations/model.ts b/packages/mobile/src/features/conversations/model.ts new file mode 100644 index 0000000..c030ff6 --- /dev/null +++ b/packages/mobile/src/features/conversations/model.ts @@ -0,0 +1,228 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { Conversation, LastMessagePreview, Message } from '@altricade/core'; +import { conversationChannel, userChannel, EventType } from '@altricade/core'; +import { + listConversations, + createDirect, + createGroup, + createChannel, + clearConversation, + hideConversation, +} from '@altricade/core/api'; +import { apiConfig } from '@/api'; +import { useRealtime } from '@/ws'; + +export interface UseConversations { + conversations: Conversation[]; + loading: boolean; + onlineMap: Record; + startDirect: (username: string) => Promise; + createGroupChat: (title: string, members: string[]) => Promise; + createChannelChat: (title: string, description: string | null, members: string[]) => Promise; + clearChat: (conversationId: string) => Promise; + deleteChat: (conversationId: string) => Promise; +} + +const hasType = (d: unknown): d is { type: string } => + typeof d === 'object' && d !== null && 'type' in d && typeof d.type === 'string'; + +const isConversationNew = (d: unknown): d is { conversation: Conversation } => + hasType(d) && d.type === EventType.ConversationNew && 'conversation' in d; +const isMessageNew = (d: unknown): d is { message: Message } => + hasType(d) && d.type === EventType.MessageNew && 'message' in d; +const isHidden = (d: unknown): d is { conversationId: string } => + hasType(d) && d.type === EventType.ConversationHidden && 'conversationId' in d; +const isCleared = (d: unknown): d is { conversationId: string } => + hasType(d) && d.type === EventType.ConversationCleared && 'conversationId' in d; + +const previewOf = (message: Message): LastMessagePreview => ({ + senderId: message.senderId, + senderName: message.sender.displayName, + content: message.content, + mediaKind: message.media?.kind ?? null, + deleted: message.deletedAt !== null, +}); + +const upsert = (list: Conversation[], conversation: Conversation): Conversation[] => [ + conversation, + ...list.filter((c) => c.id !== conversation.id), +]; + +const byRecency = (a: Conversation, b: Conversation): number => + b.lastMessageAt.localeCompare(a.lastMessageAt); + +export const useConversations = (userId: string): UseConversations => { + const { subscribe, onPresence, presence } = useRealtime(); + const [conversations, setConversations] = useState([]); + const [loading, setLoading] = useState(true); + const [onlineMap, setOnlineMap] = useState>({}); + const presentByChannel = useRef>>(new Map()); + + const recomputeOnline = useCallback((): void => { + const online: Record = {}; + for (const set of presentByChannel.current.values()) { + for (const uid of set) { + online[uid] = true; + } + } + setOnlineMap(online); + }, []); + + const bump = useCallback( + (message: Message): void => { + const mine = message.senderId === userId; + setConversations((prev) => + prev + .map((c) => + c.id === message.conversationId + ? { + ...c, + lastMessageAt: message.createdAt, + lastMessage: previewOf(message), + unreadCount: mine ? c.unreadCount : c.unreadCount + 1, + } + : c, + ) + .sort(byRecency), + ); + }, + [userId], + ); + + useEffect(() => { + let cancelled = false; + void listConversations(apiConfig) + .then((list) => { + if (!cancelled) { + setConversations(list); + } + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, []); + + // Personal channel: new conversations + DM arrivals + per-user view state. + useEffect( + () => + subscribe(userChannel(userId), (event) => { + const d = event.data; + if (isConversationNew(d)) { + setConversations((prev) => upsert(prev, d.conversation)); + } else if (isMessageNew(d)) { + bump(d.message); + } else if (isHidden(d)) { + setConversations((prev) => prev.filter((c) => c.id !== d.conversationId)); + } else if (isCleared(d)) { + setConversations((prev) => + prev.map((c) => (c.id === d.conversationId ? { ...c, unreadCount: 0 } : c)), + ); + } + }), + [subscribe, userId, bump], + ); + + // Per-conversation channels: group message arrivals + live presence. + const convKey = conversations + .map((c) => c.id) + .sort((a, b) => a.localeCompare(b)) + .join(','); + useEffect(() => { + const ids = convKey.split(',').filter((id) => id.length > 0); + const cleanups: (() => void)[] = []; + for (const id of ids) { + const channel = conversationChannel(id); + cleanups.push( + subscribe(channel, (event) => { + if (isMessageNew(event.data)) { + bump(event.data.message); + } + }), + ); + cleanups.push( + onPresence(channel, (action, uid) => { + const set = presentByChannel.current.get(channel) ?? new Set(); + if (action === 'join') { + set.add(uid); + } else { + set.delete(uid); + } + presentByChannel.current.set(channel, set); + recomputeOnline(); + }), + ); + void presence(channel).then((uids) => { + presentByChannel.current.set(channel, new Set(uids)); + recomputeOnline(); + }); + } + return () => { + for (const cleanup of cleanups) { + cleanup(); + } + }; + }, [convKey, subscribe, onPresence, presence, recomputeOnline, bump]); + + const startDirect = useCallback(async (username: string): Promise => { + const conversation = await createDirect(apiConfig, { username }); + setConversations((prev) => upsert(prev, conversation)); + return conversation; + }, []); + + const createGroupChat = useCallback( + async (title: string, members: string[]): Promise => { + const conversation = await createGroup(apiConfig, { title, members }); + setConversations((prev) => upsert(prev, conversation)); + return conversation; + }, + [], + ); + + 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) => + prev.map((c) => (c.id === conversationId ? { ...c, unreadCount: 0 } : c)), + ); + }, []); + + const deleteChat = useCallback(async (conversationId: string): Promise => { + setConversations((prev) => prev.filter((c) => c.id !== conversationId)); + await hideConversation(apiConfig, conversationId); + }, []); + + return { + conversations, + loading, + onlineMap, + startDirect, + createGroupChat, + createChannelChat, + clearChat, + deleteChat, + }; +}; + +export const markConversationRead = ( + setter: (updater: (prev: Conversation[]) => Conversation[]) => void, + conversationId: string, +): void => { + setter((prev) => prev.map((c) => (c.id === conversationId ? { ...c, unreadCount: 0 } : c))); +}; diff --git a/packages/mobile/src/features/messaging/attachments.ts b/packages/mobile/src/features/messaging/attachments.ts new file mode 100644 index 0000000..3bb49b2 --- /dev/null +++ b/packages/mobile/src/features/messaging/attachments.ts @@ -0,0 +1,81 @@ +import * as ImagePicker from 'expo-image-picker'; +import * as DocumentPicker from 'expo-document-picker'; +import type { MediaAsset } from './uploads'; + +const sizeOf = async (uri: string, known: number | null | undefined): Promise => { + if (known !== null && known !== undefined && known > 0) { + return known; + } + try { + const blob = await (await fetch(uri)).blob(); + return blob.size > 0 ? blob.size : 1; + } catch { + return 1; + } +}; + +// Normalise a picked image/video into a MediaAsset with dimensions/duration for +// deterministic layout before upload. +const fromImagePicker = async ( + asset: ImagePicker.ImagePickerAsset, + videoNote: boolean, +): Promise => { + const isVideo = asset.type === 'video'; + const kind = videoNote ? 'video_note' : isVideo ? 'video' : 'image'; + const mime = asset.mimeType ?? (isVideo ? 'video/mp4' : 'image/jpeg'); + const name = asset.fileName ?? `${kind}-${asset.assetId ?? 'file'}`; + const size = await sizeOf(asset.uri, asset.fileSize); + const media: MediaAsset = { uri: asset.uri, mime, size, name, kind }; + if (asset.width > 0) media.width = asset.width; + if (asset.height > 0) media.height = asset.height; + if (asset.duration !== null && asset.duration !== undefined && asset.duration > 0) { + media.durationSec = asset.duration / 1000; + } + return media; +}; + +export const pickFromLibrary = async (): Promise => { + const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!permission.granted) { + return null; + } + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images', 'videos'], + quality: 0.85, + }); + const asset = result.canceled ? null : (result.assets[0] ?? null); + return asset === null ? null : fromImagePicker(asset, false); +}; + +export const captureFromCamera = async (videoNote: boolean): Promise => { + const permission = await ImagePicker.requestCameraPermissionsAsync(); + if (!permission.granted) { + return null; + } + const result = await ImagePicker.launchCameraAsync({ + mediaTypes: videoNote ? ['videos'] : ['images', 'videos'], + quality: 0.85, + ...(videoNote ? { videoMaxDuration: 60 } : {}), + }); + const asset = result.canceled ? null : (result.assets[0] ?? null); + return asset === null ? null : fromImagePicker(asset, videoNote); +}; + +export const pickDocument = async (): Promise => { + const result = await DocumentPicker.getDocumentAsync({ copyToCacheDirectory: true }); + if (result.canceled) { + return null; + } + const asset = result.assets[0]; + if (asset === undefined) { + return null; + } + const size = await sizeOf(asset.uri, asset.size); + return { + uri: asset.uri, + mime: asset.mimeType ?? 'application/octet-stream', + size, + name: asset.name, + kind: 'file', + }; +}; diff --git a/packages/mobile/src/features/messaging/events.ts b/packages/mobile/src/features/messaging/events.ts new file mode 100644 index 0000000..e2a0ec8 --- /dev/null +++ b/packages/mobile/src/features/messaging/events.ts @@ -0,0 +1,64 @@ +import type { + MessageNewEvent, + MessageEditEvent, + MessageDeleteEvent, + MessageHiddenEvent, + ConversationClearedEvent, + ReactionEvent, + ReadReceiptEvent, + TypingEvent, + ReactionSummary, +} from '@altricade/core'; +import { EventType } from '@altricade/core'; + +// ---- realtime event narrowing (payloads arrive as `unknown` off the socket) ---- + +const hasType = (data: unknown): data is { type: string } => + typeof data === 'object' && data !== null && 'type' in data && typeof data.type === 'string'; + +export const isMessageEvent = (d: unknown): d is MessageNewEvent | MessageEditEvent => + hasType(d) && (d.type === EventType.MessageNew || d.type === EventType.MessageEdit) && 'message' in d; + +export const isDeleteEvent = (d: unknown): d is MessageDeleteEvent => + hasType(d) && d.type === EventType.MessageDelete && 'messageId' in d && 'conversationId' in d; + +export const isReactionEvent = (d: unknown): d is ReactionEvent => + hasType(d) && (d.type === EventType.ReactionAdd || d.type === EventType.ReactionRemove); + +export const isReadEvent = (d: unknown): d is ReadReceiptEvent => + hasType(d) && d.type === EventType.ReadReceipt; + +export const isHiddenEvent = (d: unknown): d is MessageHiddenEvent => + hasType(d) && d.type === EventType.MessageHidden; + +export const isClearedEvent = (d: unknown): d is ConversationClearedEvent => + hasType(d) && d.type === EventType.ConversationCleared; + +export const isPinEvent = (d: unknown): d is { type: string; conversationId: string } => + hasType(d) && (d.type === EventType.MessagePin || d.type === EventType.MessageUnpin); + +export const isTypingEvent = (d: unknown): d is TypingEvent => + hasType(d) && (d.type === EventType.TypingStart || d.type === EventType.TypingStop); + +// ---- pure reaction reducer (mirrors the server's aggregate) ---- + +export const applyReaction = ( + reactions: ReactionSummary[], + emoji: string, + delta: 1 | -1, + fromMe: boolean, +): ReactionSummary[] => { + const existing = reactions.find((r) => r.emoji === emoji); + if (existing === undefined) { + return delta === 1 ? [...reactions, { emoji, count: 1, mine: fromMe }] : reactions; + } + return reactions + .map((r) => { + if (r.emoji !== emoji) { + return r; + } + const mine = fromMe ? delta === 1 : r.mine; + return { emoji, count: r.count + delta, mine }; + }) + .filter((r) => r.count > 0); +}; diff --git a/packages/mobile/src/features/messaging/index.ts b/packages/mobile/src/features/messaging/index.ts new file mode 100644 index 0000000..cabeea4 --- /dev/null +++ b/packages/mobile/src/features/messaging/index.ts @@ -0,0 +1,5 @@ +export { ChatScreen } from './ui/ChatScreen'; +export { ForwardScreen } from './ui/ForwardScreen'; +export { useConversationMessages } from './model'; +export type { UseConversationMessages } from './model'; +export type { MediaAsset } from './uploads'; diff --git a/packages/mobile/src/features/messaging/media.ts b/packages/mobile/src/features/messaging/media.ts new file mode 100644 index 0000000..5e7587a --- /dev/null +++ b/packages/mobile/src/features/messaging/media.ts @@ -0,0 +1,66 @@ +import { useEffect, useState } from 'react'; +import { getMediaUrl } from '@altricade/core/api'; +import { apiConfig } from '@/api'; + +// Resolve a message's presigned GET url lazily (media bytes never travel the +// realtime channel — only a reference does). Returns null until resolved. +export const useMediaUrl = ( + conversationId: string, + messageId: string, + enabled: boolean, +): string | null => { + const [url, setUrl] = useState(null); + useEffect(() => { + if (!enabled) { + return; + } + let cancelled = false; + void getMediaUrl(apiConfig, conversationId, messageId) + .then((resolved) => { + if (!cancelled) { + setUrl(resolved); + } + }) + .catch(() => { + /* gone / not permitted */ + }); + return () => { + cancelled = true; + }; + }, [conversationId, messageId, enabled]); + return url; +}; + +// Box a media item into a max width/height while preserving aspect ratio, so +// the bubble reserves the right space before the bytes load. +export const fitBox = ( + width: number | undefined, + height: number | undefined, + maxW: number, + maxH: number, +): { width: number; height: number } => { + if (width === undefined || height === undefined || width <= 0 || height <= 0) { + return { width: maxW, height: Math.round(maxW * 0.7) }; + } + const scale = Math.min(maxW / width, maxH / height, 1); + return { width: Math.round(width * scale), height: Math.round(height * scale) }; +}; + +export const formatDuration = (seconds: number): string => { + const total = Math.max(0, Math.round(seconds)); + const mins = Math.floor(total / 60); + const secs = total % 60; + return `${String(mins)}:${secs.toString().padStart(2, '0')}`; +}; + +export const formatBytes = (bytes: number): string => { + if (bytes < 1024) return `${String(bytes)} B`; + const units = ['KB', 'MB', 'GB']; + let value = bytes / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${value.toFixed(1)} ${units[unit] ?? 'KB'}`; +}; diff --git a/packages/mobile/src/features/messaging/model.ts b/packages/mobile/src/features/messaging/model.ts new file mode 100644 index 0000000..7ea73e9 --- /dev/null +++ b/packages/mobile/src/features/messaging/model.ts @@ -0,0 +1,455 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { Conversation, Message, PublicUser, TypingEvent } from '@altricade/core'; +import { + conversationChannel, + userChannel, + ephemeralChannel, + mergeMessages, + OPTIMISTIC_SEQ, + EventType, +} from '@altricade/core'; +import { + getHistory, + getMessageContext, + sendMessage, + editMessage as apiEdit, + deleteMessage as apiDelete, + hideMessage as apiHide, + addReaction, + removeReaction, + markRead, + listPinned, +} from '@altricade/core/api'; +import { apiConfig } from '@/api'; +import { useRealtime } from '@/ws'; +import { newId } from '@/utils/id'; +import { + isMessageEvent, + isDeleteEvent, + isReactionEvent, + isReadEvent, + isHiddenEvent, + isClearedEvent, + isPinEvent, + isTypingEvent, + applyReaction, +} from './events'; +import { useUploads } from './uploads'; +import type { MediaAsset, PendingUpload } from './uploads'; + +const PAGE = 50; +const TYPING_TTL_MS = 4000; +const TYPING_THROTTLE_MS = 2500; + +export interface UseConversationMessages { + messages: Message[]; + loading: boolean; + typingUserIds: string[]; + peerReadSeq: number; + pinned: Message[]; + detached: boolean; + hasMoreUp: boolean; + uploads: PendingUpload[]; + send: (content: string, replyToId?: string) => Promise; + sendMedia: (asset: MediaAsset, caption: string) => Promise; + retryUpload: (uploadId: string) => void; + cancelUpload: (uploadId: string) => void; + jumpTo: (messageId: string) => Promise; + loadOlder: () => Promise; + loadNewer: () => Promise; + reloadTail: () => Promise; + edit: (messageId: string, content: string) => Promise; + remove: (messageId: string) => Promise; + hide: (messageId: string) => Promise; + toggleReaction: (message: Message, emoji: string) => Promise; + notifyTyping: () => void; +} + +const maxSeqOf = (list: readonly Message[], start = 0): number => + list.reduce((max, m) => (m.seq === OPTIMISTIC_SEQ ? max : Math.max(max, m.seq)), start); + +export const useConversationMessages = ( + conversation: Conversation, + me: PublicUser, +): UseConversationMessages => { + const { subscribe, publish } = useRealtime(); + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + const [typingUserIds, setTypingUserIds] = useState([]); + const [peerReadSeq, setPeerReadSeq] = useState(0); + const [pinned, setPinned] = useState([]); + const [detached, setDetached] = useState(false); + const [hasMoreUp, setHasMoreUp] = useState(true); + + const messagesRef = useRef([]); + messagesRef.current = messages; + const latestSeqRef = useRef(0); + const detachedRef = useRef(false); + detachedRef.current = detached; + const pagingRef = useRef(false); + const typingTimers = useRef>>(new Map()); + const lastTypingSent = useRef(0); + + const conversationId = conversation.id; + const groupChannel = conversation.type !== 'direct' ? conversationChannel(conversationId) : null; + + // Merge a confirmed message only while viewing the live tail; a detached + // window pulls it in via loadNewer/reloadTail instead. + const mergeIfLive = useCallback((message: Message): void => { + if (!detachedRef.current) { + setMessages((prev) => mergeMessages(prev, [message])); + } + }, []); + const { uploads, sendMedia, retryUpload, cancelUpload } = useUploads(conversationId, mergeIfLive); + + useEffect(() => { + let cancelled = false; + setMessages([]); + setLoading(true); + setTypingUserIds([]); + setPeerReadSeq(0); + setPinned([]); + setDetached(false); + setHasMoreUp(true); + latestSeqRef.current = 0; + pagingRef.current = false; + const timers = typingTimers.current; + + const refreshPinned = (): void => { + void listPinned(apiConfig, conversationId) + .then((list) => { + if (!cancelled) { + setPinned(list); + } + }) + .catch(() => { + /* not permitted / gone */ + }); + }; + refreshPinned(); + + const clearTyping = (userId: string): void => { + setTypingUserIds((prev) => prev.filter((id) => id !== userId)); + }; + + const handler = (event: { data: unknown }): void => { + const data = event.data; + if (isMessageEvent(data)) { + if (data.message.conversationId !== conversationId) { + return; + } + const { message } = data; + if (message.seq !== OPTIMISTIC_SEQ) { + latestSeqRef.current = Math.max(latestSeqRef.current, message.seq); + } + if (data.type === EventType.MessageEdit) { + setMessages((prev) => + prev.some((m) => m.id === message.id) ? mergeMessages(prev, [message]) : prev, + ); + return; + } + mergeIfLive(message); + return; + } + if (isDeleteEvent(data)) { + if (data.conversationId === conversationId) { + const { messageId } = data; + setMessages((prev) => + prev.map((m) => + m.id === messageId + ? { ...m, content: '', deletedAt: new Date().toISOString() } + : m, + ), + ); + } + return; + } + if (isReactionEvent(data)) { + if (data.conversationId === conversationId) { + const { messageId, emoji, userId } = data; + const delta = data.type === EventType.ReactionAdd ? 1 : -1; + setMessages((prev) => + prev.map((m) => + m.id === messageId + ? { ...m, reactions: applyReaction(m.reactions, emoji, delta, userId === me.id) } + : m, + ), + ); + } + return; + } + if (isReadEvent(data)) { + if (data.conversationId === conversationId && data.userId !== me.id) { + setPeerReadSeq((prev) => Math.max(prev, data.seq)); + } + return; + } + if (isHiddenEvent(data)) { + if (data.conversationId === conversationId) { + const { messageId } = data; + setMessages((prev) => prev.filter((m) => m.id !== messageId)); + } + return; + } + if (isClearedEvent(data)) { + if (data.conversationId === conversationId) { + const { upToSeq } = data; + setMessages((prev) => prev.filter((m) => m.seq > upToSeq)); + } + return; + } + if (isPinEvent(data)) { + if (data.conversationId === conversationId) { + refreshPinned(); + } + return; + } + if (isTypingEvent(data)) { + if (data.conversationId !== conversationId || data.userId === me.id) { + return; + } + const { userId } = data; + if (data.type === EventType.TypingStop) { + clearTyping(userId); + return; + } + setTypingUserIds((prev) => (prev.includes(userId) ? prev : [...prev, userId])); + const existing = timers.get(userId); + if (existing !== undefined) { + clearTimeout(existing); + } + timers.set( + userId, + setTimeout(() => { + clearTyping(userId); + }, TYPING_TTL_MS), + ); + } + }; + + const unsubUser = subscribe(userChannel(me.id), handler); + const unsubGroup = groupChannel !== null ? subscribe(groupChannel, handler) : null; + const unsubEphemeral = subscribe(ephemeralChannel(conversationId), handler); + + void getHistory(apiConfig, conversationId, { limit: PAGE }) + .then((history) => { + if (cancelled) { + return; + } + latestSeqRef.current = maxSeqOf(history, latestSeqRef.current); + if (history.length < PAGE) { + setHasMoreUp(false); + } + setMessages((prev) => mergeMessages(prev, history)); + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + + return () => { + cancelled = true; + unsubUser(); + unsubGroup?.(); + unsubEphemeral(); + for (const timer of timers.values()) { + clearTimeout(timer); + } + timers.clear(); + }; + }, [conversationId, groupChannel, subscribe, me.id, mergeIfLive]); + + // Advance read state to the newest confirmed message in view. + useEffect(() => { + const seq = maxSeqOf(messages); + if (seq > 0) { + void markRead(apiConfig, conversationId, seq); + } + }, [messages, conversationId]); + + const reloadTail = useCallback(async (): Promise => { + const history = await getHistory(apiConfig, conversationId, { limit: PAGE }); + latestSeqRef.current = maxSeqOf(history, latestSeqRef.current); + setHasMoreUp(history.length >= PAGE); + setDetached(false); + detachedRef.current = false; + setMessages(mergeMessages([], history)); + }, [conversationId]); + + const jumpTo = useCallback( + async (messageId: string): Promise => { + if (messagesRef.current.some((m) => m.id === messageId)) { + return true; + } + try { + const context = await getMessageContext(apiConfig, conversationId, messageId, 60); + if (!context.some((m) => m.id === messageId)) { + return false; + } + const maxSeq = maxSeqOf(context); + latestSeqRef.current = Math.max(latestSeqRef.current, maxSeq); + const nowDetached = maxSeq < latestSeqRef.current; + setMessages(mergeMessages([], context)); + setDetached(nowDetached); + detachedRef.current = nowDetached; + setHasMoreUp(true); + return true; + } catch { + return false; + } + }, + [conversationId], + ); + + const loadOlder = useCallback(async (): Promise => { + if (pagingRef.current) { + return; + } + const first = messagesRef.current.find((m) => m.seq !== OPTIMISTIC_SEQ); + if (first === undefined) { + return; + } + pagingRef.current = true; + try { + const batch = await getHistory(apiConfig, conversationId, { before: first.seq, limit: PAGE }); + if (batch.length < PAGE) { + setHasMoreUp(false); + } + if (batch.length > 0) { + setMessages((prev) => mergeMessages(prev, batch)); + } + } finally { + pagingRef.current = false; + } + }, [conversationId]); + + const loadNewer = useCallback(async (): Promise => { + if (pagingRef.current || !detachedRef.current) { + return; + } + const real = messagesRef.current.filter((m) => m.seq !== OPTIMISTIC_SEQ); + const last = real[real.length - 1]; + if (last === undefined) { + return; + } + pagingRef.current = true; + try { + const batch = await getHistory(apiConfig, conversationId, { after: last.seq, limit: PAGE }); + if (batch.length > 0) { + latestSeqRef.current = maxSeqOf(batch, latestSeqRef.current); + setMessages((prev) => mergeMessages(prev, batch)); + } + if (batch.length < PAGE) { + setDetached(false); + detachedRef.current = false; + } + } finally { + pagingRef.current = false; + } + }, [conversationId]); + + const send = useCallback( + async (content: string, replyToId?: string): Promise => { + if (detachedRef.current) { + await reloadTail(); + } + const clientMsgId = newId(); + const optimistic: Message = { + id: `optimistic:${clientMsgId}`, + conversationId, + senderId: me.id, + sender: me, + content, + contentType: 'text', + encryption: null, + clientMsgId, + seq: OPTIMISTIC_SEQ, + createdAt: new Date().toISOString(), + editedAt: null, + deletedAt: null, + reactions: [], + media: null, + replyTo: null, + forwarded: false, + forwardedFrom: null, + }; + setMessages((prev) => mergeMessages(prev, [optimistic])); + const confirmed = await sendMessage(apiConfig, conversationId, { + content, + clientMsgId, + ...(replyToId !== undefined ? { replyToId } : {}), + }); + setMessages((prev) => mergeMessages(prev, [confirmed])); + }, + [conversationId, me, reloadTail], + ); + + const edit = useCallback( + async (messageId: string, content: string): Promise => { + const updated = await apiEdit(apiConfig, conversationId, messageId, { content }); + setMessages((prev) => mergeMessages(prev, [updated])); + }, + [conversationId], + ); + + const remove = useCallback( + async (messageId: string): Promise => { + await apiDelete(apiConfig, conversationId, messageId); + }, + [conversationId], + ); + + const hide = useCallback( + async (messageId: string): Promise => { + setMessages((prev) => prev.filter((m) => m.id !== messageId)); + await apiHide(apiConfig, conversationId, messageId); + }, + [conversationId], + ); + + const toggleReaction = useCallback( + async (message: Message, emoji: string): Promise => { + const mine = message.reactions.some((r) => r.emoji === emoji && r.mine); + if (mine) { + await removeReaction(apiConfig, conversationId, message.id, emoji); + } else { + await addReaction(apiConfig, conversationId, message.id, { emoji }); + } + }, + [conversationId], + ); + + const notifyTyping = useCallback((): void => { + const now = Date.now(); + if (now - lastTypingSent.current > TYPING_THROTTLE_MS) { + lastTypingSent.current = now; + const event: TypingEvent = { type: EventType.TypingStart, conversationId, userId: me.id }; + void publish(ephemeralChannel(conversationId), event); + } + }, [conversationId, me.id, publish]); + + return { + messages, + loading, + typingUserIds, + peerReadSeq, + pinned, + detached, + hasMoreUp, + uploads, + send, + sendMedia, + retryUpload, + cancelUpload, + jumpTo, + loadOlder, + loadNewer, + reloadTail, + edit, + remove, + hide, + toggleReaction, + notifyTyping, + }; +}; diff --git a/packages/mobile/src/features/messaging/rows.ts b/packages/mobile/src/features/messaging/rows.ts new file mode 100644 index 0000000..93b355b --- /dev/null +++ b/packages/mobile/src/features/messaging/rows.ts @@ -0,0 +1,22 @@ +import type { Message } from '@altricade/core'; +import { formatDayLabel } from '@/utils/time'; + +export type ChatRow = + | { kind: 'divider'; id: string; label: string } + | { kind: 'message'; id: string; message: Message }; + +// Interleave day-divider rows between messages (ascending by seq). The list +// renders these directly, so ordering/grouping stays out of the components. +export const buildChatRows = (messages: readonly Message[]): ChatRow[] => { + const rows: ChatRow[] = []; + let lastDay = ''; + for (const message of messages) { + const day = new Date(message.createdAt).toDateString(); + if (day !== lastDay) { + lastDay = day; + rows.push({ kind: 'divider', id: `divider:${day}`, label: formatDayLabel(message.createdAt) }); + } + rows.push({ kind: 'message', id: message.id, message }); + } + return rows; +}; diff --git a/packages/mobile/src/features/messaging/ui/ChatHeader.tsx b/packages/mobile/src/features/messaging/ui/ChatHeader.tsx new file mode 100644 index 0000000..2b34649 --- /dev/null +++ b/packages/mobile/src/features/messaging/ui/ChatHeader.tsx @@ -0,0 +1,80 @@ +import type { ReactElement } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import type { Conversation } from '@altricade/core'; +import { Avatar, IconButton } from '@/components'; +import { useTheme } from '@/theme'; +import { spacing, fontSize } from '@/theme'; + +interface Props { + conversation: Conversation; + online: boolean; + typing: boolean; + onBack: () => void; + onOpenInfo: () => void; +} + +const titleOf = (c: Conversation): string => + c.type === 'direct' ? (c.peer?.displayName ?? 'Direct') : (c.title ?? 'Chat'); + +const subtitleOf = (c: Conversation, online: boolean, typing: boolean): string => { + if (typing) { + return 'typing…'; + } + if (c.type === 'direct') { + return online ? 'online' : 'last seen recently'; + } + return c.type === 'channel' ? 'Channel' : 'Group'; +}; + +// Chat top bar: back, avatar+title+presence, and an info entry point. +export const ChatHeader = ({ + conversation, + online, + typing, + onBack, + onOpenInfo, +}: Props): ReactElement => { + const { colors } = useTheme(); + const icon = + conversation.type === 'channel' ? 'megaphone' : conversation.type === 'group' ? 'users' : undefined; + return ( + + + + + + + {titleOf(conversation)} + + + {subtitleOf(conversation, online, typing)} + + + + + ); +}; + +const styles = StyleSheet.create({ + bar: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + center: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, + text: { flex: 1 }, + title: { fontSize: fontSize.md, fontWeight: '700' }, + subtitle: { fontSize: fontSize.xs }, +}); diff --git a/packages/mobile/src/features/messaging/ui/ChatScreen.tsx b/packages/mobile/src/features/messaging/ui/ChatScreen.tsx new file mode 100644 index 0000000..6c7e55e --- /dev/null +++ b/packages/mobile/src/features/messaging/ui/ChatScreen.tsx @@ -0,0 +1,526 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { ReactElement } from 'react'; +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; +import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'; +// Reanimated-driven drop-in: pads frame-by-frame with the keyboard animation, +// so the composer moves as one piece with it (RN's own KeyboardAvoidingView +// jumps after the keyboard settles). +import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; +import { LegendList } from '@legendapp/list'; +import type { LegendListRef } from '@legendapp/list'; +import { useRouter } from 'expo-router'; +import * as Clipboard from 'expo-clipboard'; +import * as Haptics from 'expo-haptics'; +import type { Conversation, Message, PublicUser } from '@altricade/core'; +import { getConversation, pinMessage, unpinMessage } from '@altricade/core/api'; +import { apiConfig } from '@/api'; +import { useSession } from '@/stores/session'; +import { Screen } from '@/components'; +import { useTheme } from '@/theme'; +import { spacing, radius, fontSize } from '@/theme'; +import { useConversationMessages } from '../model'; +import { buildChatRows } from '../rows'; +import { pickFromLibrary, captureFromCamera, pickDocument } from '../attachments'; +import type { MediaAsset } from '../uploads'; +import { ChatHeader } from './ChatHeader'; +import { PinnedBar } from './PinnedBar'; +import { MessageBubble } from './MessageBubble'; +import { SwipeToReply } from './SwipeToReply'; +import { Composer } from './Composer'; +import { VoiceRecorder } from './VoiceRecorder'; +import { TypingIndicator } from './TypingIndicator'; +import { MessageActionSheet } from './MessageActionSheet'; +import { MediaViewer } from './MediaViewer'; +import type { ViewerSource } from './MediaViewer'; +import { ActionSheet } from '@/components'; +import type { SheetAction } from '@/components'; + +const toPublicUser = (user: { + id: string; + username: string; + displayName: string; + avatarUrl: string | null; +}): PublicUser => ({ + id: user.id, + username: user.username, + displayName: user.displayName, + avatarUrl: user.avatarUrl, +}); + +interface Props { + conversationId: string; +} + +// Session-scoped scroll positions: reopening a chat you scrolled up in lands +// on the same message; chats left at (or near) the bottom reopen at the newest +// message. Anchored to a message id (not a pixel offset) so the position +// survives remounts AND anchors older than the initial 50-message window — +// those are restored via the same context fetch reply-jumps use. +const scrollMemory = new Map(); +const NEAR_BOTTOM_PX = 80; + +export const ChatScreen = ({ conversationId }: Props): ReactElement => { + const { colors } = useTheme(); + const user = useSession((s) => s.user); + const [conversation, setConversation] = useState(null); + + useEffect(() => { + let cancelled = false; + void getConversation(apiConfig, conversationId).then((c) => { + if (!cancelled) { + setConversation(c); + } + }); + return () => { + cancelled = true; + }; + }, [conversationId]); + + if (user === null || conversation === null) { + return ( + + + + + + ); + } + return ; +}; + +const ChatBody = ({ + conversation, + me, +}: { + conversation: Conversation; + me: PublicUser; +}): ReactElement => { + const { colors } = useTheme(); + const router = useRouter(); + const model = useConversationMessages(conversation, me); + const listRef = useRef(null); + + const [replyingTo, setReplyingTo] = useState(null); + const [editing, setEditing] = useState(null); + const [actionTarget, setActionTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [attachOpen, setAttachOpen] = useState(false); + const [recording, setRecording] = useState(false); + const [viewer, setViewer] = useState(null); + const [highlightId, setHighlightId] = useState(null); + + const rows = useMemo(() => buildChatRows(model.messages), [model.messages]); + const rowsRef = useRef(rows); + rowsRef.current = rows; + const pinnedIds = useMemo(() => new Set(model.pinned.map((m) => m.id)), [model.pinned]); + const isGroup = conversation.type !== 'direct'; + // A jump target whose context window hasn't rendered yet (out-of-window jumps). + const pendingJumpRef = useRef<{ id: string; animated: boolean } | null>(null); + // Topmost visible message row — the scroll-restore anchor candidate. + const topViewableRef = useRef(null); + + const scrollToRow = useCallback((messageId: string, animated = true): boolean => { + const index = rowsRef.current.findIndex( + (row) => row.kind === 'message' && row.message.id === messageId, + ); + if (index < 0) { + return false; + } + listRef.current?.scrollToIndex({ index, viewPosition: 0.5, animated }); + return true; + }, []); + + const jumpToMessage = useCallback( + async (messageId: string): Promise => { + const ok = await model.jumpTo(messageId); + if (!ok) { + return; + } + // In-window targets scroll now; out-of-window ones wait for the fetched + // context to render (the rows effect below consumes the pending id). + if (!scrollToRow(messageId)) { + pendingJumpRef.current = { id: messageId, animated: true }; + } + setHighlightId(messageId); + setTimeout(() => { + setHighlightId(null); + }, 1600); + }, + [model, scrollToRow], + ); + + useEffect(() => { + const pending = pendingJumpRef.current; + if (pending !== null && scrollToRow(pending.id, pending.animated)) { + pendingJumpRef.current = null; + } + }, [rows, scrollToRow]); + + // Restore the saved position once history is in: anchors inside the initial + // window scroll instantly; older anchors fetch their context first. + const restoredRef = useRef(false); + useEffect(() => { + if (model.loading || restoredRef.current) { + return; + } + restoredRef.current = true; + const anchor = scrollMemory.get(conversation.id); + if (anchor === undefined) { + return; + } + void model.jumpTo(anchor).then((ok) => { + if (!ok) { + // Original gone (hidden/cleared) — fall back to the newest message. + scrollMemory.delete(conversation.id); + listRef.current?.scrollToEnd({ animated: false }); + return; + } + if (!scrollToRow(anchor, false)) { + pendingJumpRef.current = { id: anchor, animated: false }; + } + }); + }, [model.loading, model, conversation.id, scrollToRow]); + + const onViewableItemsChanged = useCallback( + (info: { viewableItems: { key: string; isViewable: boolean }[] }): void => { + const first = info.viewableItems.find((token) => token.isViewable); + const key = first?.key; + topViewableRef.current = key !== undefined && !key.startsWith('divider:') ? key : null; + }, + [], + ); + + const onScroll = useCallback( + (event: NativeSyntheticEvent): void => { + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + if (contentOffset.y < 240 && model.hasMoreUp) { + void model.loadOlder(); + } + // Continuously persist the anchor; leaving near the bottom clears the + // memory so the chat reopens at the newest message. + const fromBottom = contentSize.height - contentOffset.y - layoutMeasurement.height; + if (fromBottom < NEAR_BOTTOM_PX) { + scrollMemory.delete(conversation.id); + } else { + const anchor = topViewableRef.current; + if (anchor !== null) { + scrollMemory.set(conversation.id, anchor); + } + } + }, + [model, conversation.id], + ); + + // Sending always follows to the newest message (Telegram behavior), even if + // the reader had scrolled up through history. + const followNewest = useCallback((): void => { + scrollMemory.delete(conversation.id); + listRef.current?.scrollToEnd({ animated: true }); + }, [conversation.id]); + + const sendMediaAsset = useCallback( + (asset: MediaAsset): void => { + followNewest(); + void model.sendMedia(asset, ''); + }, + [model, followNewest], + ); + + const runAttachment = useCallback( + async (pick: () => Promise): Promise => { + const asset = await pick(); + if (asset !== null) { + sendMediaAsset(asset); + } + }, + [sendMediaAsset], + ); + + const attachActions: SheetAction[] = [ + { + key: 'gallery', + label: 'Photo or Video', + icon: 'image', + onPress: () => { + void runAttachment(pickFromLibrary); + }, + }, + { + key: 'camera', + label: 'Camera', + icon: 'camera', + onPress: () => { + void runAttachment(() => captureFromCamera(false)); + }, + }, + { + key: 'videoNote', + label: 'Video message', + icon: 'play', + onPress: () => { + void runAttachment(() => captureFromCamera(true)); + }, + }, + { + key: 'file', + label: 'File', + icon: 'file', + onPress: () => { + void runAttachment(pickDocument); + }, + }, + ]; + + const deleteActions: SheetAction[] = + deleteTarget === null + ? [] + : [ + { + key: 'me', + label: 'Delete for me', + icon: 'trash', + onPress: () => { + void model.hide(deleteTarget.id); + }, + }, + ...(deleteTarget.senderId === me.id + ? [ + { + key: 'all', + label: 'Delete for everyone', + icon: 'trash' as const, + destructive: true, + onPress: () => { + void model.remove(deleteTarget.id); + }, + }, + ] + : []), + ]; + + const handleLongPress = useCallback((message: Message): void => { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + setActionTarget(message); + }, []); + + const openViewer = useCallback((url: string, isVideo: boolean): void => { + setViewer({ url, isVideo }); + }, []); + + const pressUser = useCallback( + (username: string): void => { + router.push(`/profile/${username}`); + }, + [router], + ); + + const bubbleCallbacks = { + onLongPress: handleLongPress, + onReplyPress: (messageId: string) => { + void jumpToMessage(messageId); + }, + onToggleReaction: (message: Message, emoji: string) => { + void model.toggleReaction(message, emoji); + }, + onOpenViewer: openViewer, + onPressUser: pressUser, + }; + + return ( + + 0} + onBack={() => { + router.back(); + }} + onOpenInfo={() => { + router.push( + isGroup ? `/group/${conversation.id}` : `/profile/${conversation.peer?.username ?? ''}`, + ); + }} + /> + { + void jumpToMessage(id); + }} + /> + + {model.loading ? ( + + + + ) : ( + item.id} + estimatedItemSize={64} + recycleItems + alignItemsAtEnd + maintainScrollAtEnd + maintainVisibleContentPosition + onLoad={() => { + // No saved anchor → open pinned to the newest message. (Never an + // initialScrollIndex: on short content it computes a bogus offset + // that shoves the last bubble under the composer.) + if (scrollMemory.get(conversation.id) === undefined) { + listRef.current?.scrollToEnd({ animated: false }); + } + }} + onViewableItemsChanged={onViewableItemsChanged} + viewabilityConfig={{ itemVisiblePercentThreshold: 10 }} + onScroll={onScroll} + contentContainerStyle={styles.listContent} + renderItem={({ item }) => + item.kind === 'divider' ? ( + + + {item.label} + + + ) : ( + { + setEditing(null); + setReplyingTo(item.message); + }} + > + + + ) + } + /> + )} + {model.typingUserIds.length > 0 ? : null} + {recording ? ( + { + sendMediaAsset(asset); + setRecording(false); + }} + onCancel={() => { + setRecording(false); + }} + /> + ) : ( + { + setReplyingTo(null); + }} + onCancelEdit={() => { + setEditing(null); + }} + onSend={(text) => { + followNewest(); + void model.send(text, replyingTo?.id); + setReplyingTo(null); + }} + onEditSubmit={(text) => { + if (editing !== null) { + void model.edit(editing.id, text); + } + setEditing(null); + }} + onAttach={() => { + setAttachOpen(true); + }} + onStartVoice={() => { + setRecording(true); + }} + onTyping={model.notifyTyping} + /> + )} + + + { + setActionTarget(null); + }} + onReact={(emoji) => { + if (actionTarget !== null) { + void model.toggleReaction(actionTarget, emoji); + } + }} + onReply={() => { + setEditing(null); + setReplyingTo(actionTarget); + }} + onCopy={() => { + if (actionTarget !== null) { + void Clipboard.setStringAsync(actionTarget.content); + } + }} + onEdit={() => { + setReplyingTo(null); + setEditing(actionTarget); + }} + onTogglePin={() => { + if (actionTarget !== null) { + const pinned = pinnedIds.has(actionTarget.id); + const call = pinned ? unpinMessage : pinMessage; + void call(apiConfig, conversation.id, actionTarget.id); + } + }} + onForward={() => { + if (actionTarget !== null) { + router.push(`/forward?ids=${actionTarget.id}&from=${conversation.id}`); + } + }} + onDelete={() => { + setDeleteTarget(actionTarget); + }} + /> + { + setDeleteTarget(null); + }} + /> + { + setAttachOpen(false); + }} + /> + { + setViewer(null); + }} + /> + + ); +}; + +const styles = StyleSheet.create({ + flex: { flex: 1 }, + center: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + listContent: { paddingVertical: spacing.sm }, + divider: { alignItems: 'center', marginVertical: spacing.sm }, + dividerText: { + fontSize: fontSize.xs, + fontWeight: '600', + paddingHorizontal: spacing.md, + paddingVertical: 3, + borderRadius: radius.full, + overflow: 'hidden', + }, +}); diff --git a/packages/mobile/src/features/messaging/ui/Composer.tsx b/packages/mobile/src/features/messaging/ui/Composer.tsx new file mode 100644 index 0000000..d2a7f1d --- /dev/null +++ b/packages/mobile/src/features/messaging/ui/Composer.tsx @@ -0,0 +1,137 @@ +import { useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; +import type { Message } from '@altricade/core'; +import { Icon, IconButton } from '@/components'; +import { useTheme } from '@/theme'; +import { spacing, radius, fontSize } from '@/theme'; + +interface Props { + replyingTo: Message | null; + editing: Message | null; + onCancelReply: () => void; + onCancelEdit: () => void; + onSend: (text: string) => void; + onEditSubmit: (text: string) => void; + onAttach: () => void; + onStartVoice: () => void; + onTyping: () => void; +} + +// Message input with contextual reply/edit banners; toggles between a voice +// affordance (empty) and a send button (has text). +export const Composer = ({ + replyingTo, + editing, + onCancelReply, + onCancelEdit, + onSend, + onEditSubmit, + onAttach, + onStartVoice, + onTyping, +}: Props): ReactElement => { + const { colors } = useTheme(); + const [text, setText] = useState(''); + + useEffect(() => { + setText(editing !== null ? editing.content : ''); + }, [editing]); + + const trimmed = text.trim(); + const canSend = trimmed.length > 0; + + const submit = (): void => { + if (!canSend) { + return; + } + if (editing !== null) { + onEditSubmit(trimmed); + } else { + onSend(trimmed); + } + setText(''); + }; + + const banner = editing ?? replyingTo; + const bannerLabel = editing !== null ? 'Editing' : replyingTo?.sender.displayName; + + return ( + + {banner !== null ? ( + + + + + {bannerLabel} + + + {banner.content.length > 0 ? banner.content : 'Media message'} + + + + + + + ) : null} + + {editing === null ? ( + + ) : null} + { + setText(value); + onTyping(); + }} + placeholder="Message" + placeholderTextColor={colors.textFaint} + multiline + style={[styles.input, { color: colors.text, backgroundColor: colors.surface }]} + /> + {canSend ? ( + + + + ) : ( + + + + )} + + + ); +}; + +const styles = StyleSheet.create({ + wrap: { borderTopWidth: StyleSheet.hairlineWidth }, + banner: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingTop: spacing.sm, + }, + bannerBar: { width: 3, height: 32, borderRadius: 2 }, + bannerBody: { flex: 1, gap: 1 }, + bannerName: { fontSize: fontSize.sm, fontWeight: '700' }, + bannerText: { fontSize: fontSize.sm }, + row: { + flexDirection: 'row', + alignItems: 'flex-end', + gap: spacing.sm, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.sm, + }, + input: { + flex: 1, + maxHeight: 120, + minHeight: 44, + borderRadius: radius.xl, + paddingHorizontal: spacing.md, + paddingTop: spacing.sm, + paddingBottom: spacing.sm, + fontSize: fontSize.md, + }, + send: { width: 44, height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center' }, +}); diff --git a/packages/mobile/src/features/messaging/ui/FileBubble.tsx b/packages/mobile/src/features/messaging/ui/FileBubble.tsx new file mode 100644 index 0000000..1e75623 --- /dev/null +++ b/packages/mobile/src/features/messaging/ui/FileBubble.tsx @@ -0,0 +1,51 @@ +import type { ReactElement } from 'react'; +import { Linking, Pressable, StyleSheet, Text, View } from 'react-native'; +import type { MediaRef } from '@altricade/core'; +import { Icon } from '@/components'; +import { useTheme } from '@/theme'; +import { spacing, radius, fontSize } from '@/theme'; +import { useMediaUrl, formatBytes } from '../media'; + +interface Props { + conversationId: string; + messageId: string; + media: MediaRef; + outgoing: boolean; +} + +// Generic file attachment — tapping opens the presigned url in the OS handler. +export const FileBubble = ({ conversationId, messageId, media, outgoing }: Props): ReactElement => { + const { colors } = useTheme(); + const url = useMediaUrl(conversationId, messageId, true); + const nameTone = outgoing ? colors.onAccent : colors.text; + const subTone = outgoing ? colors.onAccentMuted : colors.textFaint; + return ( + { + if (url !== null) { + void Linking.openURL(url); + } + }} + style={styles.row} + > + + + + + + {media.name} + + {formatBytes(media.size)} + + + ); +}; + +const styles = StyleSheet.create({ + row: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, minWidth: 180 }, + icon: { width: 42, height: 42, borderRadius: radius.md, alignItems: 'center', justifyContent: 'center' }, + meta: { flex: 1, gap: 2 }, + name: { fontSize: fontSize.base, fontWeight: '600' }, + size: { fontSize: fontSize.xs }, +}); diff --git a/packages/mobile/src/features/messaging/ui/ForwardScreen.tsx b/packages/mobile/src/features/messaging/ui/ForwardScreen.tsx new file mode 100644 index 0000000..5327d5f --- /dev/null +++ b/packages/mobile/src/features/messaging/ui/ForwardScreen.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import { Pressable, StyleSheet, Text } from 'react-native'; +import { LegendList } from '@legendapp/list'; +import { useRouter } from 'expo-router'; +import type { Conversation } from '@altricade/core'; +import { listConversations, forwardMessage } from '@altricade/core/api'; +import { apiConfig } from '@/api'; +import { Avatar, Screen, ScreenHeader } from '@/components'; +import { useTheme } from '@/theme'; +import { spacing, fontSize } from '@/theme'; + +interface Props { + messageIds: string[]; + fromConversationId: string; +} + +const titleOf = (c: Conversation): string => + c.type === 'direct' ? (c.peer?.displayName ?? 'Direct') : (c.title ?? 'Chat'); + +// Pick a destination conversation and copy the selected message(s) into it. +export const ForwardScreen = ({ messageIds, fromConversationId }: Props): ReactElement => { + const { colors } = useTheme(); + const router = useRouter(); + const [conversations, setConversations] = useState([]); + const [busy, setBusy] = useState(false); + + useEffect(() => { + void listConversations(apiConfig).then(setConversations); + }, []); + + const forwardTo = (target: Conversation): void => { + if (busy) { + return; + } + setBusy(true); + const run = async (): Promise => { + for (const messageId of messageIds) { + await forwardMessage(apiConfig, target.id, { + sourceConversationId: fromConversationId, + messageId, + }); + } + }; + void run() + .then(() => { + router.replace(`/chat/${target.id}`); + }) + .finally(() => { + setBusy(false); + }); + }; + + return ( + + + item.id} + estimatedItemSize={62} + renderItem={({ item }) => { + const icon = + item.type === 'channel' ? 'megaphone' : item.type === 'group' ? 'users' : undefined; + return ( + { + forwardTo(item); + }} + style={({ pressed }) => [styles.row, pressed && { backgroundColor: colors.surfaceHover }]} + > + + + {titleOf(item)} + + + ); + }} + /> + + ); +}; + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + }, + name: { flex: 1, fontSize: fontSize.md, fontWeight: '600' }, +}); diff --git a/packages/mobile/src/features/messaging/ui/ForwardedHeader.tsx b/packages/mobile/src/features/messaging/ui/ForwardedHeader.tsx new file mode 100644 index 0000000..1120c1f --- /dev/null +++ b/packages/mobile/src/features/messaging/ui/ForwardedHeader.tsx @@ -0,0 +1,40 @@ +import type { ReactElement } from 'react'; +import { Pressable, StyleSheet, Text } from 'react-native'; +import type { ForwardOrigin } from '@altricade/core'; +import { useTheme } from '@/theme'; +import { spacing, fontSize } from '@/theme'; + +interface Props { + origin: ForwardOrigin | null; + outgoing: boolean; + onPressUser?: (username: string) => void; +} + +// "Forwarded from …" attribution. A null origin on a forwarded message means the +// original sender's account is hidden. +export const ForwardedHeader = ({ origin, outgoing, onPressUser }: Props): ReactElement => { + const { colors } = useTheme(); + const label = outgoing ? colors.onAccentMuted : colors.textFaint; + const name = outgoing ? colors.onAccent : colors.accent; + const username = origin?.user?.username ?? null; + return ( + { + if (username !== null) { + onPressUser?.(username); + } + }} + > + Forwarded from + + {origin?.name ?? 'Hidden account'} + + + ); +}; + +const styles = StyleSheet.create({ + label: { fontSize: fontSize.xs, marginBottom: 1 }, + name: { fontSize: fontSize.sm, fontWeight: '700', marginBottom: spacing.xs }, +}); diff --git a/packages/mobile/src/features/messaging/ui/MediaContent.tsx b/packages/mobile/src/features/messaging/ui/MediaContent.tsx new file mode 100644 index 0000000..c4bacb8 --- /dev/null +++ b/packages/mobile/src/features/messaging/ui/MediaContent.tsx @@ -0,0 +1,59 @@ +import type { ReactElement } from 'react'; +import type { MediaRef } from '@altricade/core'; +import { MediaImage } from './MediaImage'; +import { VoicePlayer } from './VoicePlayer'; +import { VideoNote } from './VideoNote'; +import { FileBubble } from './FileBubble'; + +interface Props { + conversationId: string; + messageId: string; + media: MediaRef; + outgoing: boolean; + localUri?: string; + onOpenViewer: (url: string, isVideo: boolean) => void; +} + +// Dispatch a message's media reference to the right player/tile by kind. +export const MediaContent = ({ + conversationId, + messageId, + media, + outgoing, + localUri, + onOpenViewer, +}: Props): ReactElement => { + switch (media.kind) { + case 'image': + case 'video': + return ( + + ); + case 'video_note': + return ; + case 'voice': + return ( + + ); + default: + return ( + + ); + } +}; diff --git a/packages/mobile/src/features/messaging/ui/MediaImage.tsx b/packages/mobile/src/features/messaging/ui/MediaImage.tsx new file mode 100644 index 0000000..a2a60ee --- /dev/null +++ b/packages/mobile/src/features/messaging/ui/MediaImage.tsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; +import { Image } from 'expo-image'; +import * as VideoThumbnails from 'expo-video-thumbnails'; +import type { MediaRef } from '@altricade/core'; +import { Icon } from '@/components'; +import { useTheme } from '@/theme'; +import { radius } from '@/theme'; +import { useMediaUrl, fitBox } from '../media'; + +// Video tiles need a real still frame: feeding a video URL to an image +// component decodes nothing (ImageIO can't read mp4) — the tile stays blank. +// Outcomes are cached per message ('' = extraction failed) so list recycling +// neither re-extracts thumbs nor re-attempts undecodable codecs (WebM/AV1). +const thumbCache = new Map(); + +const useVideoThumbnail = (messageId: string, source: string | null, isVideo: boolean): string | null => { + const [thumb, setThumb] = useState(() => { + const cached = thumbCache.get(messageId); + return cached !== undefined && cached !== '' ? cached : null; + }); + useEffect(() => { + if (!isVideo || source === null || thumbCache.has(messageId)) { + return undefined; + } + let cancelled = false; + void VideoThumbnails.getThumbnailAsync(source, { time: 0 }) + .then(({ uri }) => { + thumbCache.set(messageId, uri); + if (!cancelled) { + setThumb(uri); + } + }) + .catch(() => { + // Undecodable on this device — remember, keep the plain tile. + thumbCache.set(messageId, ''); + }); + return () => { + cancelled = true; + }; + }, [messageId, source, isVideo]); + return thumb; +}; + +interface Props { + conversationId: string; + messageId: string; + media: MediaRef; + /** Local uri while an outgoing upload is in flight (skips the presign fetch). */ + localUri?: string; + onOpen: (url: string, isVideo: boolean) => void; +} + +const MAX_W = 260; +const MAX_H = 340; + +// Image / video-poster tile. Videos overlay a play badge; tapping opens the +// full-screen viewer. +export const MediaImage = ({ + conversationId, + messageId, + media, + localUri, + onOpen, +}: Props): ReactElement => { + const { colors } = useTheme(); + const remote = useMediaUrl(conversationId, messageId, localUri === undefined); + const uri = localUri ?? remote; + const box = fitBox(media.width, media.height, MAX_W, MAX_H); + const isVideo = media.kind === 'video'; + const thumb = useVideoThumbnail(messageId, uri, isVideo); + const displayUri = isVideo ? thumb : uri; + + return ( + { + if (uri !== null) { + onOpen(uri, isVideo); + } + }} + style={[styles.wrap, box, { backgroundColor: colors.surface }]} + > + {displayUri !== null ? ( + + ) : null} + {isVideo ? ( + + + + + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + wrap: { borderRadius: radius.md, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }, + overlay: { ...StyleSheet.absoluteFillObject, alignItems: 'center', justifyContent: 'center' }, + playBadge: { + width: 52, + height: 52, + borderRadius: 26, + backgroundColor: 'rgba(0,0,0,0.45)', + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/packages/mobile/src/features/messaging/ui/MediaViewer.tsx b/packages/mobile/src/features/messaging/ui/MediaViewer.tsx new file mode 100644 index 0000000..c1fab38 --- /dev/null +++ b/packages/mobile/src/features/messaging/ui/MediaViewer.tsx @@ -0,0 +1,108 @@ +import type { ReactElement } from 'react'; +import { Modal, Pressable, StyleSheet, View } from 'react-native'; +import { Image } from 'expo-image'; +import { useVideoPlayer, VideoView } from 'expo-video'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Icon } from '@/components'; + +export interface ViewerSource { + url: string; + isVideo: boolean; +} + +interface Props { + source: ViewerSource | null; + onClose: () => void; +} + +const ZoomableImage = ({ url }: { url: string }): ReactElement => { + const scale = useSharedValue(1); + const savedScale = useSharedValue(1); + const translateX = useSharedValue(0); + const translateY = useSharedValue(0); + + const pinch = Gesture.Pinch() + .onUpdate((event) => { + scale.value = Math.max(1, savedScale.value * event.scale); + }) + .onEnd(() => { + savedScale.value = scale.value; + if (scale.value <= 1) { + translateX.value = withTiming(0); + translateY.value = withTiming(0); + } + }); + const pan = Gesture.Pan() + .onUpdate((event) => { + if (scale.value > 1) { + translateX.value = event.translationX; + translateY.value = event.translationY; + } + }); + const composed = Gesture.Simultaneous(pinch, pan); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [ + { translateX: translateX.value }, + { translateY: translateY.value }, + { scale: scale.value }, + ], + })); + + return ( + + + + + + ); +}; + +const ViewerVideo = ({ url }: { url: string }): ReactElement => { + const player = useVideoPlayer({ uri: url }, (instance) => { + instance.play(); + }); + return ; +}; + +// Full-screen image (pinch-zoom) / video overlay opened from a media bubble. +export const MediaViewer = ({ source, onClose }: Props): ReactElement => { + const insets = useSafeAreaInsets(); + return ( + + + {source !== null ? ( + source.isVideo ? ( + + ) : ( + + ) + ) : null} + + + + + + ); +}; + +const styles = StyleSheet.create({ + backdrop: { flex: 1, backgroundColor: '#000' }, + fill: { flex: 1, width: '100%', height: '100%' }, + close: { + position: 'absolute', + right: 16, + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: 'rgba(0,0,0,0.5)', + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/packages/mobile/src/features/messaging/ui/MessageActionSheet.tsx b/packages/mobile/src/features/messaging/ui/MessageActionSheet.tsx new file mode 100644 index 0000000..93a5ea2 --- /dev/null +++ b/packages/mobile/src/features/messaging/ui/MessageActionSheet.tsx @@ -0,0 +1,100 @@ +import type { ReactElement } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import type { Message } from '@altricade/core'; +import { ActionSheet } from '@/components'; +import type { SheetAction } from '@/components'; +import { useTheme } from '@/theme'; +import { spacing, radius } from '@/theme'; + +const QUICK_REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '🙏']; + +export interface MessageActionHandlers { + onReact: (emoji: string) => void; + onReply: () => void; + onCopy: () => void; + onEdit: () => void; + onTogglePin: () => void; + onForward: () => void; + onDelete: () => void; +} + +interface Props extends MessageActionHandlers { + message: Message | null; + meId: string; + isPinned: boolean; + onClose: () => void; +} + +// Long-press context menu: a quick-reactions strip plus the message actions, +// built for the specific message (own vs. others, text vs. media). +export const MessageActionSheet = ({ + message, + meId, + isPinned, + onReact, + onReply, + onCopy, + onEdit, + onTogglePin, + onForward, + onDelete, + onClose, +}: Props): ReactElement => { + const { colors } = useTheme(); + const outgoing = message !== null && message.senderId === meId; + const hasText = message !== null && message.content.length > 0; + + const actions: SheetAction[] = message === null ? [] : [ + { key: 'reply', label: 'Reply', icon: 'reply', onPress: onReply }, + ...(hasText ? [{ key: 'copy', label: 'Copy', icon: 'copy' as const, onPress: onCopy }] : []), + ...(outgoing && hasText && message.media === null + ? [{ key: 'edit', label: 'Edit', icon: 'edit' as const, onPress: onEdit }] + : []), + { + key: 'pin', + label: isPinned ? 'Unpin' : 'Pin', + icon: isPinned ? 'pinOff' : 'pin', + onPress: onTogglePin, + }, + { key: 'forward', label: 'Forward', icon: 'forward', onPress: onForward }, + { key: 'delete', label: 'Delete', icon: 'trash', destructive: true, onPress: onDelete }, + ]; + + const header = message === null ? undefined : ( + + {QUICK_REACTIONS.map((emoji) => ( + { + onReact(emoji); + }} + style={styles.reaction} + > + {emoji} + + ))} + + ); + + return ( + + ); +}; + +const styles = StyleSheet.create({ + reactions: { + flexDirection: 'row', + justifyContent: 'space-around', + borderRadius: radius.full, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.sm, + marginBottom: spacing.sm, + }, + reaction: { paddingHorizontal: spacing.xs }, + emoji: { fontSize: 26 }, +}); diff --git a/packages/mobile/src/features/messaging/ui/MessageBubble.tsx b/packages/mobile/src/features/messaging/ui/MessageBubble.tsx new file mode 100644 index 0000000..e8b8a68 --- /dev/null +++ b/packages/mobile/src/features/messaging/ui/MessageBubble.tsx @@ -0,0 +1,149 @@ +import { memo } from 'react'; +import type { ReactElement } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import type { Message } from '@altricade/core'; +import { OPTIMISTIC_SEQ } from '@altricade/core'; +import { useTheme } from '@/theme'; +import { spacing, radius, fontSize } from '@/theme'; +import { MessageMeta } from './MessageMeta'; +import type { SendStatus } from './MessageMeta'; +import { ReplyQuote } from './ReplyQuote'; +import { ForwardedHeader } from './ForwardedHeader'; +import { Reactions } from './Reactions'; +import { MediaContent } from './MediaContent'; + +export interface BubbleCallbacks { + onLongPress: (message: Message) => void; + onReplyPress: (messageId: string) => void; + onToggleReaction: (message: Message, emoji: string) => void; + onOpenViewer: (url: string, isVideo: boolean) => void; + onPressUser: (username: string) => void; +} + +interface Props extends BubbleCallbacks { + message: Message; + meId: string; + isGroup: boolean; + peerReadSeq: number; + highlighted: boolean; +} + +const statusOf = (message: Message, outgoing: boolean, peerReadSeq: number): SendStatus => { + if (message.seq === OPTIMISTIC_SEQ) return 'pending'; + if (outgoing && peerReadSeq >= message.seq) return 'read'; + return 'sent'; +}; + +const MessageBubbleInner = ({ + message, + meId, + isGroup, + peerReadSeq, + highlighted, + onLongPress, + onReplyPress, + onToggleReaction, + onOpenViewer, + onPressUser, +}: Props): ReactElement => { + const { colors } = useTheme(); + const outgoing = message.senderId === meId; + const deleted = message.deletedAt !== null; + const showSender = isGroup && !outgoing && !deleted; + const bubbleColor = outgoing ? colors.bubbleOut : colors.bubbleIn; + const textColor = outgoing ? colors.onAccent : colors.text; + const hasText = message.content.length > 0 && !deleted; + + return ( + + { + if (!deleted) { + onLongPress(message); + } + }} + delayLongPress={280} + style={[ + styles.bubble, + { backgroundColor: bubbleColor, borderColor: colors.border }, + outgoing ? styles.bubbleOut : styles.bubbleIn, + highlighted && { borderColor: colors.accent, borderWidth: 2 }, + ]} + > + {message.forwarded ? ( + + ) : null} + {showSender ? ( + + {message.sender.displayName} + + ) : null} + {message.replyTo !== null ? ( + { + if (message.replyTo !== null) { + onReplyPress(message.replyTo.id); + } + }} + /> + ) : null} + {message.media !== null && !deleted ? ( + + + + ) : null} + {deleted ? ( + + Message deleted + + ) : hasText ? ( + {message.content} + ) : null} + + + {!deleted ? ( + { + onToggleReaction(message, emoji); + }} + /> + ) : null} + + ); +}; + +export const MessageBubble = memo(MessageBubbleInner); + +const styles = StyleSheet.create({ + line: { paddingHorizontal: spacing.md, marginVertical: 2, maxWidth: '100%' }, + lineOut: { alignItems: 'flex-end' }, + lineIn: { alignItems: 'flex-start' }, + bubble: { + maxWidth: '82%', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.lg, + gap: 2, + }, + bubbleOut: { borderBottomRightRadius: radius.sm }, + bubbleIn: { borderBottomLeftRadius: radius.sm, borderWidth: StyleSheet.hairlineWidth }, + sender: { fontSize: fontSize.sm, fontWeight: '700', marginBottom: 1 }, + text: { fontSize: fontSize.base, lineHeight: 21 }, + deleted: { fontSize: fontSize.base, fontStyle: 'italic' }, + media: { marginBottom: 2 }, +}); diff --git a/packages/mobile/src/features/messaging/ui/MessageMeta.tsx b/packages/mobile/src/features/messaging/ui/MessageMeta.tsx new file mode 100644 index 0000000..fafdd42 --- /dev/null +++ b/packages/mobile/src/features/messaging/ui/MessageMeta.tsx @@ -0,0 +1,37 @@ +import type { ReactElement } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { useTheme } from '@/theme'; +import { fontSize } from '@/theme'; +import { formatClock } from '@/utils/time'; + +export type SendStatus = 'pending' | 'sent' | 'read'; + +interface Props { + createdAt: string; + edited: boolean; + outgoing: boolean; + status: SendStatus; +} + +// Time + "edited" + delivery ticks, shown inline at the end of a bubble. +export const MessageMeta = ({ createdAt, edited, outgoing, status }: Props): ReactElement => { + const { colors } = useTheme(); + const tone = outgoing ? colors.onAccentMuted : colors.textFaint; + return ( + + {edited ? edited : null} + {formatClock(createdAt)} + {outgoing ? ( + + {status === 'pending' ? '·' : status === 'read' ? '✓✓' : '✓'} + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + row: { flexDirection: 'row', alignItems: 'center', gap: 4, alignSelf: 'flex-end' }, + text: { fontSize: fontSize.xs }, + tick: { fontSize: fontSize.xs, fontWeight: '700', letterSpacing: -1 }, +}); diff --git a/packages/mobile/src/features/messaging/ui/PinnedBar.tsx b/packages/mobile/src/features/messaging/ui/PinnedBar.tsx new file mode 100644 index 0000000..90c20b0 --- /dev/null +++ b/packages/mobile/src/features/messaging/ui/PinnedBar.tsx @@ -0,0 +1,54 @@ +import type { ReactElement } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import type { Message } from '@altricade/core'; +import { Icon } from '@/components'; +import { useTheme } from '@/theme'; +import { spacing, fontSize } from '@/theme'; + +interface Props { + pinned: Message[]; + onJump: (messageId: string) => void; +} + +// Slim bar above the chat showing the most recent pinned message; tap jumps to +// it. Hidden when nothing is pinned. +export const PinnedBar = ({ pinned, onJump }: Props): ReactElement | null => { + const { colors } = useTheme(); + const top = pinned[0]; + if (top === undefined) { + return null; + } + const preview = top.content.length > 0 ? top.content : top.media !== null ? 'Media message' : ''; + return ( + { + onJump(top.id); + }} + style={[styles.bar, { backgroundColor: colors.surfacePanel, borderColor: colors.border }]} + > + + + + Pinned{pinned.length > 1 ? ` · ${String(pinned.length)}` : ''} + + + {preview} + + + + ); +}; + +const styles = StyleSheet.create({ + bar: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + body: { flex: 1, gap: 1 }, + label: { fontSize: fontSize.xs, fontWeight: '700' }, + text: { fontSize: fontSize.sm }, +}); diff --git a/packages/mobile/src/features/messaging/ui/Reactions.tsx b/packages/mobile/src/features/messaging/ui/Reactions.tsx new file mode 100644 index 0000000..0dbe761 --- /dev/null +++ b/packages/mobile/src/features/messaging/ui/Reactions.tsx @@ -0,0 +1,59 @@ +import type { ReactElement } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import type { ReactionSummary } from '@altricade/core'; +import { useTheme } from '@/theme'; +import { spacing, radius, fontSize } from '@/theme'; + +interface Props { + reactions: ReactionSummary[]; + outgoing: boolean; + onToggle: (emoji: string) => void; +} + +// Reaction chips under a bubble; the user's own reactions are highlighted. +export const Reactions = ({ reactions, outgoing, onToggle }: Props): ReactElement | null => { + const { colors } = useTheme(); + if (reactions.length === 0) { + return null; + } + return ( + + {reactions.map((reaction) => ( + { + onToggle(reaction.emoji); + }} + style={[ + styles.chip, + { + backgroundColor: reaction.mine ? colors.accentSoft : colors.surface, + borderColor: reaction.mine ? colors.accent : colors.border, + }, + ]} + > + {reaction.emoji} + + {reaction.count} + + + ))} + + ); +}; + +const styles = StyleSheet.create({ + row: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs, marginTop: spacing.xs }, + rowOut: { justifyContent: 'flex-end' }, + chip: { + flexDirection: 'row', + alignItems: 'center', + gap: 3, + paddingHorizontal: spacing.sm, + paddingVertical: 3, + borderRadius: radius.full, + borderWidth: StyleSheet.hairlineWidth, + }, + emoji: { fontSize: fontSize.sm }, + count: { fontSize: fontSize.xs, fontWeight: '700' }, +}); diff --git a/packages/mobile/src/features/messaging/ui/ReplyQuote.tsx b/packages/mobile/src/features/messaging/ui/ReplyQuote.tsx new file mode 100644 index 0000000..a43352c --- /dev/null +++ b/packages/mobile/src/features/messaging/ui/ReplyQuote.tsx @@ -0,0 +1,70 @@ +import type { ReactElement } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import type { ReplyPreview, MediaRef } from '@altricade/core'; +import { useTheme } from '@/theme'; +import { spacing, radius, fontSize } from '@/theme'; + +interface Props { + reply: ReplyPreview; + outgoing: boolean; + onPress?: () => void; +} + +const mediaHint = (kind: MediaRef['kind']): string => { + switch (kind) { + case 'image': + return 'Photo'; + case 'video': + return 'Video'; + case 'video_note': + return 'Video message'; + case 'voice': + return 'Voice message'; + default: + return 'File'; + } +}; + +// The quoted "replying to" strip rendered above a bubble's content. +export const ReplyQuote = ({ reply, outgoing, onPress }: Props): ReactElement => { + const { colors } = useTheme(); + const bar = outgoing ? colors.onAccent : colors.accent; + const nameTone = outgoing ? colors.onAccent : colors.accent; + const bodyTone = outgoing ? colors.onAccentMuted : colors.textMuted; + const body = reply.deleted + ? 'Deleted message' + : reply.content.length > 0 + ? reply.content + : reply.mediaKind !== null + ? mediaHint(reply.mediaKind) + : ''; + return ( + + + + + {reply.senderName} + + + {body} + + + + ); +}; + +const styles = StyleSheet.create({ + wrap: { + flexDirection: 'row', + borderRadius: radius.sm, + overflow: 'hidden', + marginBottom: spacing.xs, + }, + bar: { width: 3 }, + body: { flex: 1, paddingVertical: 4, paddingHorizontal: spacing.sm, gap: 1 }, + name: { fontSize: fontSize.sm, fontWeight: '700' }, + text: { fontSize: fontSize.sm }, +}); diff --git a/packages/mobile/src/features/messaging/ui/SwipeToReply.tsx b/packages/mobile/src/features/messaging/ui/SwipeToReply.tsx new file mode 100644 index 0000000..8f2ff9d --- /dev/null +++ b/packages/mobile/src/features/messaging/ui/SwipeToReply.tsx @@ -0,0 +1,77 @@ +import type { ReactElement, ReactNode } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; +import { scheduleOnRN } from 'react-native-worklets'; +import * as Haptics from 'expo-haptics'; +import { Icon } from '@/components'; +import { useTheme } from '@/theme'; + +interface Props { + onReply: () => void; + children: ReactNode; +} + +const MAX_DRAG = 88; +const THRESHOLD = 56; + +const fireHaptic = (): void => { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); +}; + +// Telegram-style swipe-right-to-reply. Horizontal drag translates the row and +// reveals a reply arrow; releasing past the threshold triggers the reply. +export const SwipeToReply = ({ onReply, children }: Props): ReactElement => { + const { colors } = useTheme(); + const translateX = useSharedValue(0); + const armed = useSharedValue(false); + + const pan = Gesture.Pan() + .activeOffsetX(14) + .failOffsetY([-12, 12]) + .onUpdate((event) => { + const next = Math.max(0, Math.min(event.translationX, MAX_DRAG)); + translateX.value = next; + if (next >= THRESHOLD && !armed.value) { + armed.value = true; + scheduleOnRN(fireHaptic); + } else if (next < THRESHOLD && armed.value) { + armed.value = false; + } + }) + .onEnd(() => { + if (translateX.value >= THRESHOLD) { + scheduleOnRN(onReply); + } + translateX.value = withSpring(0, { damping: 18, stiffness: 220 }); + armed.value = false; + }); + + const rowStyle = useAnimatedStyle(() => ({ transform: [{ translateX: translateX.value }] })); + const iconStyle = useAnimatedStyle(() => ({ + opacity: Math.min(1, translateX.value / THRESHOLD), + transform: [{ scale: Math.min(1, translateX.value / THRESHOLD) }], + })); + + return ( + + + + + + + + {children} + + + ); +}; + +const styles = StyleSheet.create({ + iconWrap: { position: 'absolute', left: 16, top: 0, bottom: 0, justifyContent: 'center' }, + icon: { width: 34, height: 34, borderRadius: 17, alignItems: 'center', justifyContent: 'center' }, +}); diff --git a/packages/mobile/src/features/messaging/ui/TypingIndicator.tsx b/packages/mobile/src/features/messaging/ui/TypingIndicator.tsx new file mode 100644 index 0000000..9b33782 --- /dev/null +++ b/packages/mobile/src/features/messaging/ui/TypingIndicator.tsx @@ -0,0 +1,52 @@ +import { useEffect } from 'react'; +import type { ReactElement } from 'react'; +import { StyleSheet, View } from 'react-native'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withDelay, + withRepeat, + withSequence, + withTiming, +} from 'react-native-reanimated'; +import { useTheme } from '@/theme'; +import { spacing } from '@/theme'; + +const Dot = ({ delay, color }: { delay: number; color: string }): ReactElement => { + const value = useSharedValue(0.3); + useEffect(() => { + value.value = withDelay( + delay, + withRepeat(withSequence(withTiming(1, { duration: 400 }), withTiming(0.3, { duration: 400 })), -1), + ); + }, [delay, value]); + const style = useAnimatedStyle(() => ({ opacity: value.value })); + return ; +}; + +// Animated three-dot "typing…" bubble shown at the tail while a peer types. +export const TypingIndicator = (): ReactElement => { + const { colors } = useTheme(); + return ( + + + + + + ); +}; + +const styles = StyleSheet.create({ + bubble: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + alignSelf: 'flex-start', + paddingHorizontal: spacing.md, + paddingVertical: spacing.md, + borderRadius: 18, + marginHorizontal: spacing.lg, + marginVertical: spacing.xs, + }, + dot: { width: 7, height: 7, borderRadius: 4 }, +}); diff --git a/packages/mobile/src/features/messaging/ui/VideoNote.tsx b/packages/mobile/src/features/messaging/ui/VideoNote.tsx new file mode 100644 index 0000000..1e6c9dc --- /dev/null +++ b/packages/mobile/src/features/messaging/ui/VideoNote.tsx @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import type { ReactElement } from 'react'; +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'; +import { useVideoPlayer, VideoView } from 'expo-video'; +import type { MediaRef } from '@altricade/core'; +import { Icon } from '@/components'; +import { useTheme } from '@/theme'; +import { fontSize } from '@/theme'; +import { useMediaUrl, formatDuration } from '../media'; + +interface Props { + conversationId: string; + messageId: string; + media: MediaRef; +} + +const SIZE = 190; + +const VideoNoteBody = ({ url, media }: { url: string; media: MediaRef }): ReactElement => { + const { colors } = useTheme(); + const [playing, setPlaying] = useState(false); + const player = useVideoPlayer({ uri: url }, (instance) => { + instance.loop = true; + }); + + return ( + { + if (playing) { + player.pause(); + setPlaying(false); + } else { + player.play(); + setPlaying(true); + } + }} + style={styles.round} + > + + {!playing ? ( + + + + ) : null} + {media.durationSec !== undefined ? ( + + + {formatDuration(media.durationSec)} + + + ) : null} + + ); +}; + +// Round "video message" (Telegram-style) — resolves the presigned url first. +export const VideoNote = ({ conversationId, messageId, media }: Props): ReactElement => { + const { colors } = useTheme(); + const url = useMediaUrl(conversationId, messageId, true); + if (url === null) { + return ( + + + + ); + } + return ; +}; + +const styles = StyleSheet.create({ + round: { width: SIZE, height: SIZE, borderRadius: SIZE / 2, overflow: 'hidden' }, + center: { alignItems: 'center', justifyContent: 'center' }, + video: { width: SIZE, height: SIZE }, + overlay: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(0,0,0,0.2)', + }, + badge: { + position: 'absolute', + bottom: 8, + alignSelf: 'center', + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 10, + }, + time: { fontSize: fontSize.xs, fontWeight: '600' }, +}); diff --git a/packages/mobile/src/features/messaging/ui/VoicePlayer.tsx b/packages/mobile/src/features/messaging/ui/VoicePlayer.tsx new file mode 100644 index 0000000..12b27e6 --- /dev/null +++ b/packages/mobile/src/features/messaging/ui/VoicePlayer.tsx @@ -0,0 +1,103 @@ +import type { ReactElement } from 'react'; +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'; +import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio'; +import type { MediaRef } from '@altricade/core'; +import { Icon } from '@/components'; +import { useTheme } from '@/theme'; +import { spacing, fontSize } from '@/theme'; +import { useMediaUrl, formatDuration } from '../media'; + +interface Props { + conversationId: string; + messageId: string; + media: MediaRef; + outgoing: boolean; +} + +const BAR_COUNT = 26; + +// Deterministic pseudo-waveform from the message id — stable across renders, +// no real amplitude data needed for the visual. +const bars = (seed: string): number[] => { + let hash = 0; + for (let i = 0; i < seed.length; i += 1) { + hash = (hash * 31 + seed.charCodeAt(i)) & 0xffff; + } + return Array.from({ length: BAR_COUNT }, (_, i) => { + hash = (hash * 1103515245 + 12345) & 0x7fffffff; + return 0.25 + ((hash >> (i % 8)) % 100) / 130; + }); +}; + +const VoicePlayerBody = ({ url, media, outgoing, messageId }: Props & { url: string }): ReactElement => { + const { colors } = useTheme(); + const player = useAudioPlayer({ uri: url }); + const status = useAudioPlayerStatus(player); + const duration = status.duration > 0 ? status.duration : (media.durationSec ?? 0); + const progress = duration > 0 ? status.currentTime / duration : 0; + const played = outgoing ? colors.onAccent : colors.accent; + const track = outgoing ? colors.onAccentMuted : colors.border; + + return ( + + { + if (status.playing) { + player.pause(); + } else { + player.play(); + } + }} + style={[styles.play, { backgroundColor: outgoing ? colors.onAccent : colors.accent }]} + > + + + + + {bars(messageId).map((height, index) => ( + + ))} + + + {formatDuration(status.playing || status.currentTime > 0 ? status.currentTime : duration)} + + + + ); +}; + +// Voice-note bubble. Resolves the presigned url before mounting the player so +// the audio hooks bind to a stable source. +export const VoicePlayer = (props: Props): ReactElement => { + const { colors } = useTheme(); + const url = useMediaUrl(props.conversationId, props.messageId, true); + if (url === null) { + return ( + + + + ); + } + return ; +}; + +const styles = StyleSheet.create({ + row: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, minWidth: 200 }, + loading: { minWidth: 200, height: 44, alignItems: 'flex-start', justifyContent: 'center' }, + play: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' }, + waveWrap: { flex: 1, gap: 2 }, + wave: { flexDirection: 'row', alignItems: 'center', gap: 2, height: 26 }, + time: { fontSize: fontSize.xs }, +}); diff --git a/packages/mobile/src/features/messaging/ui/VoiceRecorder.tsx b/packages/mobile/src/features/messaging/ui/VoiceRecorder.tsx new file mode 100644 index 0000000..d6a15d1 --- /dev/null +++ b/packages/mobile/src/features/messaging/ui/VoiceRecorder.tsx @@ -0,0 +1,131 @@ +import { useEffect } from 'react'; +import type { ReactElement } from 'react'; +import { Alert, Pressable, StyleSheet, Text, View } from 'react-native'; +import { + useAudioRecorder, + useAudioRecorderState, + RecordingPresets, + AudioModule, +} from 'expo-audio'; +import * as Haptics from 'expo-haptics'; +import { Icon } from '@/components'; +import { useTheme } from '@/theme'; +import { spacing, fontSize } from '@/theme'; +import { formatDuration } from '../media'; +import type { MediaAsset } from '../uploads'; + +interface Props { + onSend: (asset: MediaAsset) => void; + onCancel: () => void; +} + +const sizeOf = async (uri: string): Promise => { + try { + const blob = await (await fetch(uri)).blob(); + return blob.size; + } catch { + return 0; + } +}; + +// Active voice-note recording bar: starts on mount, shows a live timer, and +// resolves to a voice MediaAsset on send. +export const VoiceRecorder = ({ onSend, onCancel }: Props): ReactElement => { + const { colors } = useTheme(); + const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY); + const state = useAudioRecorderState(recorder); + + useEffect(() => { + let active = true; + const start = async (): Promise => { + const permission = await AudioModule.requestRecordingPermissionsAsync(); + if (!permission.granted) { + Alert.alert('Microphone access needed', 'Enable microphone access to record voice messages.'); + onCancel(); + return; + } + await recorder.prepareToRecordAsync(); + if (active) { + recorder.record(); + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + } + }; + void start(); + return () => { + active = false; + }; + // Recorder identity is stable for this component's lifetime. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const stop = async (): Promise => { + await recorder.stop(); + return recorder.uri; + }; + + const finish = async (): Promise => { + const uri = await stop(); + if (uri === null) { + onCancel(); + return; + } + const size = await sizeOf(uri); + onSend({ + uri, + mime: 'audio/m4a', + size: size > 0 ? size : 1, + name: 'voice-message.m4a', + kind: 'voice', + durationSec: state.durationMillis / 1000, + }); + }; + + const cancel = async (): Promise => { + await stop(); + onCancel(); + }; + + return ( + + { + void cancel(); + }} + hitSlop={8} + > + + + + + + {formatDuration(state.durationMillis / 1000)} + + Recording… + + { + void finish(); + }} + style={[styles.send, { backgroundColor: colors.accent }]} + > + + + + ); +}; + +const styles = StyleSheet.create({ + bar: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderTopWidth: StyleSheet.hairlineWidth, + }, + center: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, + pulse: { width: 12, height: 12, borderRadius: 6 }, + time: { fontSize: fontSize.md, fontWeight: '600', fontVariant: ['tabular-nums'] }, + hint: { fontSize: fontSize.sm }, + send: { width: 44, height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center' }, +}); diff --git a/packages/mobile/src/features/messaging/uploads.ts b/packages/mobile/src/features/messaging/uploads.ts new file mode 100644 index 0000000..62c3542 --- /dev/null +++ b/packages/mobile/src/features/messaging/uploads.ts @@ -0,0 +1,170 @@ +import { useCallback, useRef, useState } from 'react'; +import { Video, getVideoMetaData } from 'react-native-compressor'; +import type { MediaRef, MediaKind, Message } from '@altricade/core'; +import { getUploadUrl, uploadToUrl, sendMessage } from '@altricade/core/api'; +import { apiConfig } from '@/api'; +import { newId } from '@/utils/id'; + +// A picked/captured asset ready to send. Dimensions/duration come from the +// picker or camera (no browser-style probing on native), so layout is +// deterministic before the bytes land. +export interface MediaAsset { + uri: string; + mime: string; + size: number; + name: string; + kind: MediaKind; + width?: number; + height?: number; + durationSec?: number; +} + +export type UploadStatus = 'processing' | 'uploading' | 'sending' | 'error'; + +export interface PendingUpload { + id: string; + media: MediaRef; + caption: string; + /** Local file uri for an inline preview while the bytes are in flight. */ + previewUri: string | null; + progress: number; + status: UploadStatus; +} + +interface UploadJob { + asset: MediaAsset; + media: MediaRef; + caption: string; + objectKey: string | null; + controller: AbortController | null; + /** True once the hardware transcode ran — retries skip it. */ + normalized: boolean; +} + +const replaceExtension = (name: string, ext: string): string => { + const dot = name.lastIndexOf('.'); + const base = dot > 0 ? name.slice(0, dot) : name; + return `${base}.${ext}`; +}; + +const buildMediaRef = (asset: MediaAsset): MediaRef => { + const media: MediaRef = { + kind: asset.kind, + mime: asset.mime, + size: asset.size, + name: asset.name, + }; + if (asset.width !== undefined) media.width = asset.width; + if (asset.height !== undefined) media.height = asset.height; + if (asset.durationSec !== undefined && asset.durationSec > 0) media.durationSec = asset.durationSec; + return media; +}; + +export interface UseUploads { + uploads: PendingUpload[]; + sendMedia: (asset: MediaAsset, caption: string) => Promise; + retryUpload: (uploadId: string) => void; + cancelUpload: (uploadId: string) => void; +} + +// Presign → transfer → send, with retry/cancel. A failed step parks the entry +// in 'error' (nothing is dropped silently); retry resumes from where it failed +// (a re-send reuses the already-uploaded objectKey), reusing the clientMsgId so +// the backend dedupes. +export const useUploads = ( + conversationId: string, + onConfirmed: (message: Message) => void, +): UseUploads => { + const [uploads, setUploads] = useState([]); + const jobs = useRef>(new Map()); + + const patch = useCallback((id: string, partial: Partial): void => { + setUploads((prev) => prev.map((u) => (u.id === id ? { ...u, ...partial } : u))); + }, []); + + const runUpload = useCallback( + async (uploadId: string): Promise => { + const job = jobs.current.get(uploadId); + if (job === undefined) { + return; + } + try { + let objectKey = job.objectKey; + if (objectKey === null) { + patch(uploadId, { status: 'uploading', progress: 0 }); + const target = await getUploadUrl(apiConfig, { + kind: job.media.kind, + mime: job.asset.mime, + size: job.asset.size, + }); + const controller = new AbortController(); + job.controller = controller; + const response = await fetch(job.asset.uri); + const blob = await response.blob(); + await uploadToUrl(target.uploadUrl, blob, job.asset.mime, { + signal: controller.signal, + onProgress: (loaded, total) => { + patch(uploadId, { progress: total > 0 ? loaded / total : 0 }); + }, + }); + job.controller = null; + objectKey = target.objectKey; + job.objectKey = objectKey; + } + patch(uploadId, { status: 'sending', progress: 1 }); + const confirmed = await sendMessage(apiConfig, conversationId, { + content: job.caption, + clientMsgId: uploadId, + mediaKey: objectKey, + media: job.media, + }); + jobs.current.delete(uploadId); + setUploads((prev) => prev.filter((u) => u.id !== uploadId)); + onConfirmed(confirmed); + } catch { + job.controller = null; + if (jobs.current.has(uploadId)) { + patch(uploadId, { status: 'error' }); + } + } + }, + [conversationId, onConfirmed, patch], + ); + + const sendMedia = useCallback( + async (asset: MediaAsset, caption: string): Promise => { + const media = buildMediaRef(asset); + const uploadId = newId(); + jobs.current.set(uploadId, { asset, media, caption, objectKey: null, controller: null }); + const previewUri = + asset.kind === 'image' || asset.kind === 'video' || asset.kind === 'video_note' + ? asset.uri + : null; + setUploads((prev) => [ + ...prev, + { id: uploadId, media, caption, previewUri, progress: 0, status: 'uploading' }, + ]); + await runUpload(uploadId); + }, + [runUpload], + ); + + const retryUpload = useCallback( + (uploadId: string): void => { + void runUpload(uploadId); + }, + [runUpload], + ); + + const cancelUpload = useCallback((uploadId: string): void => { + const job = jobs.current.get(uploadId); + if (job === undefined) { + return; + } + jobs.current.delete(uploadId); + job.controller?.abort(); + setUploads((prev) => prev.filter((u) => u.id !== uploadId)); + }, []); + + return { uploads, sendMedia, retryUpload, cancelUpload }; +}; diff --git a/packages/mobile/src/features/profile/GroupInfoScreen.tsx b/packages/mobile/src/features/profile/GroupInfoScreen.tsx new file mode 100644 index 0000000..5d26df8 --- /dev/null +++ b/packages/mobile/src/features/profile/GroupInfoScreen.tsx @@ -0,0 +1,120 @@ +import { useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { useRouter } from 'expo-router'; +import type { Conversation, ConversationMember } from '@altricade/core'; +import { getConversation, listMembers, removeMember } from '@altricade/core/api'; +import { apiConfig } from '@/api'; +import { useSession } from '@/stores/session'; +import { Avatar, Icon, Screen, ScreenHeader, UserRow } from '@/components'; +import { useTheme } from '@/theme'; +import { spacing, fontSize } from '@/theme'; + +interface Props { + conversationId: string; +} + +export const GroupInfoScreen = ({ conversationId }: Props): ReactElement => { + const { colors } = useTheme(); + const router = useRouter(); + const me = useSession((s) => s.user); + const [conversation, setConversation] = useState(null); + const [members, setMembers] = useState([]); + + useEffect(() => { + void getConversation(apiConfig, conversationId).then(setConversation); + void listMembers(apiConfig, conversationId) + .then(setMembers) + .catch(() => { + /* channels may not expose members to non-admins */ + }); + }, [conversationId]); + + const leave = (): void => { + if (me === null) { + return; + } + void removeMember(apiConfig, conversationId, me.id).then(() => { + router.replace('/'); + }); + }; + + const icon = conversation?.type === 'channel' ? 'megaphone' : 'users'; + + return ( + + + {conversation === null ? ( + + + + ) : ( + + + + {conversation.title} + {conversation.description !== null ? ( + {conversation.description} + ) : null} + + + {members.length > 0 ? ( + <> + + {members.length} members + + {members.map((member) => ( + { + router.push(`/profile/${user.username}`); + }} + /> + ))} + + ) : null} + + + + + {conversation.type === 'channel' ? 'Leave channel' : 'Leave group'} + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + center: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + hero: { alignItems: 'center', gap: spacing.sm, paddingVertical: spacing.xl }, + title: { fontSize: fontSize.xl, fontWeight: '700', marginTop: spacing.sm }, + desc: { fontSize: fontSize.base, textAlign: 'center', paddingHorizontal: spacing.xl }, + section: { + fontSize: fontSize.xs, + fontWeight: '700', + textTransform: 'uppercase', + letterSpacing: 0.6, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + }, + leave: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + padding: spacing.md, + margin: spacing.lg, + borderRadius: 16, + borderWidth: StyleSheet.hairlineWidth, + }, + leaveText: { fontSize: fontSize.md, fontWeight: '600' }, +}); diff --git a/packages/mobile/src/features/profile/ProfileScreen.tsx b/packages/mobile/src/features/profile/ProfileScreen.tsx new file mode 100644 index 0000000..ce6aa58 --- /dev/null +++ b/packages/mobile/src/features/profile/ProfileScreen.tsx @@ -0,0 +1,92 @@ +import { useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'; +import { useRouter } from 'expo-router'; +import type { PublicUser } from '@altricade/core'; +import { getUserByUsername, createDirect } from '@altricade/core/api'; +import { apiConfig } from '@/api'; +import { useSession } from '@/stores/session'; +import { Avatar, Icon, Screen, ScreenHeader } from '@/components'; +import { useTheme } from '@/theme'; +import { spacing, radius, fontSize } from '@/theme'; + +interface Props { + username: string; +} + +export const ProfileScreen = ({ username }: Props): ReactElement => { + const { colors } = useTheme(); + const router = useRouter(); + const me = useSession((s) => s.user); + const [user, setUser] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let cancelled = false; + void getUserByUsername(apiConfig, username).then((profile) => { + if (!cancelled) { + setUser(profile); + } + }); + return () => { + cancelled = true; + }; + }, [username]); + + const message = (): void => { + if (user === null || busy) { + return; + } + setBusy(true); + void createDirect(apiConfig, { username: user.username }) + .then((conversation) => { + router.replace(`/chat/${conversation.id}`); + }) + .finally(() => { + setBusy(false); + }); + }; + + return ( + + + {user === null ? ( + + + + ) : ( + + + {user.displayName} + @{user.username} + {me?.id !== user.id ? ( + + + Message + + ) : null} + + )} + + ); +}; + +const styles = StyleSheet.create({ + center: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + content: { alignItems: 'center', gap: spacing.xs, paddingTop: spacing.xxl }, + name: { fontSize: fontSize.xl, fontWeight: '700', marginTop: spacing.md }, + username: { fontSize: fontSize.base }, + action: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.xl, + paddingVertical: spacing.md, + borderRadius: radius.full, + marginTop: spacing.xl, + }, + actionText: { fontSize: fontSize.md, fontWeight: '600' }, +}); diff --git a/packages/mobile/src/features/profile/index.ts b/packages/mobile/src/features/profile/index.ts new file mode 100644 index 0000000..d4f631f --- /dev/null +++ b/packages/mobile/src/features/profile/index.ts @@ -0,0 +1,2 @@ +export { ProfileScreen } from './ProfileScreen'; +export { GroupInfoScreen } from './GroupInfoScreen'; diff --git a/packages/mobile/src/features/settings/SettingsScreen.tsx b/packages/mobile/src/features/settings/SettingsScreen.tsx new file mode 100644 index 0000000..a9f9349 --- /dev/null +++ b/packages/mobile/src/features/settings/SettingsScreen.tsx @@ -0,0 +1,84 @@ +import type { ReactElement } from 'react'; +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import type { ThemePreference } from '@/theme'; +import { useSession } from '@/stores/session'; +import { Avatar, Icon, Screen, ScreenHeader } from '@/components'; +import type { IconName } from '@/components'; +import { useTheme } from '@/theme'; +import { spacing, radius, fontSize } from '@/theme'; + +const THEME_OPTIONS: { value: ThemePreference; label: string; icon: IconName }[] = [ + { value: 'system', label: 'System', icon: 'monitor' }, + { value: 'light', label: 'Light', icon: 'sun' }, + { value: 'dark', label: 'Dark', icon: 'moon' }, +]; + +export const SettingsScreen = (): ReactElement => { + const { colors, preference, setPreference } = useTheme(); + const user = useSession((s) => s.user); + const logout = useSession((s) => s.logout); + + return ( + + + + + + {user?.displayName} + @{user?.username} + + + Appearance + + {THEME_OPTIONS.map((option, index) => ( + { + setPreference(option.value); + }} + style={[styles.option, index > 0 && { borderTopColor: colors.border, borderTopWidth: StyleSheet.hairlineWidth }]} + > + + {option.label} + {preference === option.value ? ( + + ) : null} + + ))} + + + { + void logout(); + }} + style={[styles.logout, { borderColor: colors.border }]} + > + + Log out + + + + ); +}; + +const styles = StyleSheet.create({ + content: { padding: spacing.lg, gap: spacing.md }, + profile: { alignItems: 'center', gap: spacing.xs, paddingVertical: spacing.lg }, + name: { fontSize: fontSize.xl, fontWeight: '700' }, + username: { fontSize: fontSize.base }, + section: { fontSize: fontSize.xs, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 0.6 }, + card: { borderRadius: radius.lg, borderWidth: StyleSheet.hairlineWidth, overflow: 'hidden' }, + option: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, padding: spacing.md }, + optionLabel: { flex: 1, fontSize: fontSize.md }, + logout: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + padding: spacing.md, + borderRadius: radius.lg, + borderWidth: StyleSheet.hairlineWidth, + marginTop: spacing.lg, + }, + logoutText: { fontSize: fontSize.md, fontWeight: '600' }, +}); diff --git a/packages/mobile/src/features/settings/index.ts b/packages/mobile/src/features/settings/index.ts new file mode 100644 index 0000000..6a3c3df --- /dev/null +++ b/packages/mobile/src/features/settings/index.ts @@ -0,0 +1 @@ +export { SettingsScreen } from './SettingsScreen'; diff --git a/packages/mobile/src/index.ts b/packages/mobile/src/index.ts deleted file mode 100644 index 2c584cf..0000000 --- a/packages/mobile/src/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Phase 0 placeholder. The real React Native + Expo app is built in Phase 7, -// reusing the @altricade/core realtime/API/auth layer with platform-specific UI -// following the same Feature-Sliced Design layers as web -// (app → screens → widgets → features → entities → shared). -// -// Decisions locked for Phase 7: -// - Virtualized lists use @legendapp/list (LegendList), NOT FlatList. -// - Offline-first: persistent outbox + local cache, shared logic from core. -// - Light/dark theming from shared design tokens (mirrors web/src/shared/theme). - -import { CORE_VERSION, userChannel } from '@altricade/core'; - -export const mobilePlaceholder = (): string => - `@altricade/mobile stub on core ${CORE_VERSION}; personal channel ${userChannel('me')}`; diff --git a/packages/mobile/src/services/notifications.ts b/packages/mobile/src/services/notifications.ts new file mode 100644 index 0000000..83bd581 --- /dev/null +++ b/packages/mobile/src/services/notifications.ts @@ -0,0 +1,80 @@ +import { Platform } from 'react-native'; +import * as Notifications from 'expo-notifications'; +import { registerDevice, unregisterDevice } from '@altricade/core/api'; +import { apiConfig } from '@/api'; + +// Native push (FCM/APNs) requires a development/production build with push +// credentials — it cannot run in Expo Go. Registration is therefore gated +// behind EXPO_PUBLIC_PUSH_ENABLED (default off); set it once EAS credentials +// are configured. The tap-to-route wiring below is always active so local + +// delivered notifications deep-link correctly. +export const PUSH_ENABLED = process.env['EXPO_PUBLIC_PUSH_ENABLED'] === 'true'; + +// Foreground presentation: the realtime socket already renders live messages, +// so we only surface a banner (no duplicate list spam handled on the backend). +Notifications.setNotificationHandler({ + handleNotification: () => + Promise.resolve({ + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: true, + shouldSetBadge: true, + }), +}); + +const ensureAndroidChannel = async (): Promise => { + if (Platform.OS === 'android') { + await Notifications.setNotificationChannelAsync('default', { + name: 'Messages', + importance: Notifications.AndroidImportance.HIGH, + vibrationPattern: [0, 200, 100, 200], + }); + } +}; + +const nativePlatform = (): 'ios' | 'android' | null => + Platform.OS === 'ios' ? 'ios' : Platform.OS === 'android' ? 'android' : null; + +// Request permission, obtain the device push token, and register it with the +// backend so it can dispatch pushes to this device. No-op unless enabled. +export const registerForPush = async (): Promise => { + if (!PUSH_ENABLED) { + return null; + } + const platform = nativePlatform(); + if (platform === null) { + return null; + } + const existing = await Notifications.getPermissionsAsync(); + const granted = + existing.granted || (await Notifications.requestPermissionsAsync()).granted; + if (!granted) { + return null; + } + await ensureAndroidChannel(); + const token = await Notifications.getDevicePushTokenAsync(); + const value = typeof token.data === 'string' ? token.data : String(token.data); + await registerDevice(apiConfig, { platform, token: value }); + return value; +}; + +export const unregisterFromPush = async (token: string): Promise => { + if (!PUSH_ENABLED) { + return; + } + await unregisterDevice(apiConfig, token); +}; + +// Pull a conversation id out of a notification payload for deep-linking. Push +// payloads must be self-sufficient (the JS runtime may be cold), so the +// conversation id travels in the notification data. +export const conversationIdFromNotification = ( + notification: Notifications.Notification, +): string | null => { + const data: unknown = notification.request.content.data; + if (typeof data === 'object' && data !== null && 'conversationId' in data) { + const value: unknown = data.conversationId; + return typeof value === 'string' ? value : null; + } + return null; +}; diff --git a/packages/mobile/src/services/usePush.ts b/packages/mobile/src/services/usePush.ts new file mode 100644 index 0000000..904982e --- /dev/null +++ b/packages/mobile/src/services/usePush.ts @@ -0,0 +1,26 @@ +import { useEffect } from 'react'; +import * as Notifications from 'expo-notifications'; +import { useRouter } from 'expo-router'; +import { registerForPush, conversationIdFromNotification } from './notifications'; + +// Registers this device for push (gated) and routes notification taps to the +// right conversation. `useLastNotificationResponse` fires for both a warm tap +// and a cold start opened from a notification, so one effect covers both. +export const usePush = (): void => { + const router = useRouter(); + const lastResponse = Notifications.useLastNotificationResponse(); + + useEffect(() => { + void registerForPush(); + }, []); + + useEffect(() => { + if (lastResponse === null || lastResponse === undefined) { + return; + } + const conversationId = conversationIdFromNotification(lastResponse.notification); + if (conversationId !== null) { + router.push(`/chat/${conversationId}`); + } + }, [lastResponse, router]); +}; diff --git a/packages/mobile/src/stores/session.ts b/packages/mobile/src/stores/session.ts new file mode 100644 index 0000000..8a88a0b --- /dev/null +++ b/packages/mobile/src/stores/session.ts @@ -0,0 +1,77 @@ +import { create } from 'zustand'; +import type { AuthResult, User, LoginBody, RegisterBody } from '@altricade/core'; +import { + register as apiRegister, + login as apiLogin, + refresh as apiRefresh, + logout as apiLogout, +} from '@altricade/core/api'; +import { apiConfig } from '@/api'; +import { setAccessToken, setRefreshToken, loadRefreshToken, clearTokens } from '@/api'; + +export type SessionStatus = 'loading' | 'anonymous' | 'authenticated'; + +interface SessionState { + status: SessionStatus; + user: User | null; + bootstrap: () => Promise; + login: (body: LoginBody) => Promise; + register: (body: RegisterBody) => Promise; + logout: () => Promise; + setUser: (user: User) => void; +} + +const applyAuth = async (result: AuthResult): Promise => { + setAccessToken(result.accessToken); + if (result.refreshToken !== undefined) { + await setRefreshToken(result.refreshToken); + } +}; + +export const useSession = create((set) => ({ + status: 'loading', + user: null, + + // On launch: restore the refresh token from the keychain, then rotate it for a + // fresh session. Failure (no token / revoked) → anonymous. + bootstrap: async () => { + const stored = await loadRefreshToken(); + if (stored === null) { + set({ status: 'anonymous', user: null }); + return; + } + try { + const result = await apiRefresh(apiConfig); + await applyAuth(result); + set({ status: 'authenticated', user: result.user }); + } catch { + await clearTokens(); + set({ status: 'anonymous', user: null }); + } + }, + + login: async (body) => { + const result = await apiLogin(apiConfig, body); + await applyAuth(result); + set({ status: 'authenticated', user: result.user }); + }, + + register: async (body) => { + const result = await apiRegister(apiConfig, body); + await applyAuth(result); + set({ status: 'authenticated', user: result.user }); + }, + + logout: async () => { + try { + await apiLogout(apiConfig); + } finally { + await clearTokens(); + set({ status: 'anonymous', user: null }); + } + }, + + setUser: (user) => { + set({ user }); + }, +})); diff --git a/packages/mobile/src/theme/ThemeProvider.tsx b/packages/mobile/src/theme/ThemeProvider.tsx new file mode 100644 index 0000000..6424321 --- /dev/null +++ b/packages/mobile/src/theme/ThemeProvider.tsx @@ -0,0 +1,56 @@ +import { createContext, useContext, useEffect, useMemo, useState } from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import { useColorScheme } from 'react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { themes } from './tokens'; +import type { ThemeColors, ThemeName } from './tokens'; + +export type ThemePreference = ThemeName | 'system'; + +interface ThemeContextValue { + colors: ThemeColors; + name: ThemeName; + preference: ThemePreference; + setPreference: (preference: ThemePreference) => void; +} + +const ThemeContext = createContext(null); +const STORAGE_KEY = 'zovi.theme'; + +const isPreference = (value: string | null): value is ThemePreference => + value === 'light' || value === 'dark' || value === 'system'; + +export const ThemeProvider = ({ children }: { children: ReactNode }): ReactElement => { + const system = useColorScheme(); + const [preference, setPreferenceState] = useState('system'); + + useEffect(() => { + void AsyncStorage.getItem(STORAGE_KEY).then((stored) => { + if (isPreference(stored)) { + setPreferenceState(stored); + } + }); + }, []); + + const setPreference = (next: ThemePreference): void => { + setPreferenceState(next); + void AsyncStorage.setItem(STORAGE_KEY, next); + }; + + const name: ThemeName = preference === 'system' ? (system === 'dark' ? 'dark' : 'light') : preference; + + const value = useMemo( + () => ({ colors: themes[name], name, preference, setPreference }), + [name, preference], + ); + + return {children}; +}; + +export const useTheme = (): ThemeContextValue => { + const context = useContext(ThemeContext); + if (context === null) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +}; diff --git a/packages/mobile/src/theme/index.ts b/packages/mobile/src/theme/index.ts new file mode 100644 index 0000000..64490f8 --- /dev/null +++ b/packages/mobile/src/theme/index.ts @@ -0,0 +1,4 @@ +export { ThemeProvider, useTheme } from './ThemeProvider'; +export type { ThemePreference } from './ThemeProvider'; +export { themes, spacing, radius, fontSize } from './tokens'; +export type { ThemeName, ThemeColors } from './tokens'; diff --git a/packages/mobile/src/theme/tokens.ts b/packages/mobile/src/theme/tokens.ts new file mode 100644 index 0000000..b8f4b7b --- /dev/null +++ b/packages/mobile/src/theme/tokens.ts @@ -0,0 +1,98 @@ +// Design tokens — the single source of colour truth (mirrors the web token +// system: indigo accent, layered surfaces, light + dark). No screen hard-codes +// hex; everything reads from the active theme via `useTheme`. + +export type ThemeName = 'light' | 'dark'; + +export interface ThemeColors { + background: string; + surfacePanel: string; + surface: string; + surfaceHover: string; + chatBackdrop: string; + text: string; + textMuted: string; + textFaint: string; + accent: string; + accentHover: string; + accentSoft: string; + onAccent: string; + onAccentMuted: string; + bubbleOut: string; + bubbleIn: string; + border: string; + borderStrong: string; + online: string; + danger: string; +} + +export const themes: Record = { + light: { + background: '#ffffff', + surfacePanel: '#f7f8fa', + surface: '#eef0f4', + surfaceHover: '#e9ebf0', + chatBackdrop: '#f4f5f8', + text: '#0c0d10', + textMuted: '#606a7b', + textFaint: '#9aa3b2', + accent: '#4c6fff', + accentHover: '#3a5cf5', + accentSoft: '#eaeeff', + onAccent: '#ffffff', + onAccentMuted: 'rgba(255, 255, 255, 0.72)', + bubbleOut: '#4c6fff', + bubbleIn: '#ffffff', + border: '#e6e8ee', + borderStrong: '#d4d8e0', + online: '#22c55e', + danger: '#ef4444', + }, + dark: { + background: '#0e0f13', + surfacePanel: '#15171d', + surface: '#1e222b', + surfaceHover: '#242833', + chatBackdrop: '#0b0c10', + text: '#f3f4f7', + textMuted: '#98a1b2', + textFaint: '#5f6675', + accent: '#5b7cff', + accentHover: '#6f8bff', + accentSoft: '#1b2540', + onAccent: '#ffffff', + onAccentMuted: 'rgba(255, 255, 255, 0.72)', + bubbleOut: '#3b5cf5', + bubbleIn: '#22262f', + border: '#262a33', + borderStrong: '#333844', + online: '#22c55e', + danger: '#f87171', + }, +}; + +export const spacing = { + xs: 4, + sm: 8, + md: 12, + lg: 16, + xl: 24, + xxl: 32, +} as const; + +export const radius = { + sm: 8, + md: 12, + lg: 16, + xl: 22, + full: 999, +} as const; + +export const fontSize = { + xs: 12, + sm: 13, + base: 15, + md: 16, + lg: 18, + xl: 22, +} as const; diff --git a/packages/mobile/src/utils/id.ts b/packages/mobile/src/utils/id.ts new file mode 100644 index 0000000..eb6e2e4 --- /dev/null +++ b/packages/mobile/src/utils/id.ts @@ -0,0 +1,5 @@ +import { randomUUID } from 'expo-crypto'; + +// Stable idempotency key for optimistic sends — echoed back by the backend so +// the client can reconcile its pending message with the authoritative copy. +export const newId = (): string => randomUUID(); diff --git a/packages/mobile/src/utils/time.ts b/packages/mobile/src/utils/time.ts new file mode 100644 index 0000000..4428fbe --- /dev/null +++ b/packages/mobile/src/utils/time.ts @@ -0,0 +1,30 @@ +const DAY_MS = 86_400_000; + +// Clock time (HH:MM) for message meta / conversation rows. +export const formatClock = (iso: string): string => + new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + +// Relative label for a conversation row's timestamp. +export const formatRelative = (iso: string): string => { + const then = new Date(iso); + const now = new Date(); + if (then.toDateString() === now.toDateString()) { + return formatClock(iso); + } + if (now.getTime() - then.getTime() < 6 * DAY_MS) { + return then.toLocaleDateString([], { weekday: 'short' }); + } + return then.toLocaleDateString([], { day: '2-digit', month: '2-digit' }); +}; + +// Day divider label inside a chat ("Today" / "Yesterday" / a date). +export const formatDayLabel = (iso: string): string => { + const then = new Date(iso); + const now = new Date(); + const midnight = (d: Date): number => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + const diffDays = Math.round((midnight(now) - midnight(then)) / DAY_MS); + if (diffDays === 0) return 'Today'; + if (diffDays === 1) return 'Yesterday'; + if (diffDays < 7) return then.toLocaleDateString([], { weekday: 'long' }); + return then.toLocaleDateString([], { day: 'numeric', month: 'long' }); +}; diff --git a/packages/mobile/src/ws/RealtimeProvider.tsx b/packages/mobile/src/ws/RealtimeProvider.tsx new file mode 100644 index 0000000..3612e0c --- /dev/null +++ b/packages/mobile/src/ws/RealtimeProvider.tsx @@ -0,0 +1,100 @@ +import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import { RealtimeClient } from '@altricade/core/realtime'; +import type { ConnectionState, RealtimeEvent, PresenceAction } from '@altricade/core/realtime'; +import { getCentrifugoToken } from '@altricade/core/api'; +import { apiConfig, wsUrl } from '@/api'; + +type EventHandler = (event: RealtimeEvent) => void; +type PresenceHandler = (action: PresenceAction, userId: string) => void; + +interface RealtimeContextValue { + state: ConnectionState; + /** Subscribe a handler to a channel; returns an unsubscribe cleanup. */ + subscribe: (channel: string, handler: EventHandler) => () => void; + onPresence: (channel: string, handler: PresenceHandler) => () => void; + presence: (channel: string) => Promise; + publish: (channel: string, data: unknown) => Promise; +} + +const RealtimeContext = createContext(null); + +// One Centrifugo client for the app; feature hooks multiplex handlers over it. +// Receive-only for messages (backend is the brain) + channel presence. +export const RealtimeProvider = ({ children }: { children: ReactNode }): ReactElement => { + const [state, setState] = useState('disconnected'); + const eventHandlers = useRef(new Map>()); + const presenceHandlers = useRef(new Map>()); + const clientRef = useRef(null); + + clientRef.current ??= new RealtimeClient({ + url: wsUrl, + getToken: async () => (await getCentrifugoToken(apiConfig)).token, + onState: setState, + onEvent: (event) => { + for (const handler of eventHandlers.current.get(event.channel) ?? []) { + handler(event); + } + }, + onPresence: (channel, action, userId) => { + for (const handler of presenceHandlers.current.get(channel) ?? []) { + handler(action, userId); + } + }, + }); + + useEffect(() => { + const client = clientRef.current; + client?.connect(); + return () => { + client?.disconnect(); + }; + }, []); + + const value = useMemo(() => { + const client = clientRef.current; + return { + state, + subscribe: (channel, handler) => { + const set = eventHandlers.current.get(channel) ?? new Set(); + if (set.size === 0) { + client?.subscribe(channel); + } + set.add(handler); + eventHandlers.current.set(channel, set); + return () => { + set.delete(handler); + if (set.size === 0) { + eventHandlers.current.delete(channel); + client?.unsubscribe(channel); + } + }; + }, + onPresence: (channel, handler) => { + const set = presenceHandlers.current.get(channel) ?? new Set(); + set.add(handler); + presenceHandlers.current.set(channel, set); + return () => { + set.delete(handler); + if (set.size === 0) { + presenceHandlers.current.delete(channel); + } + }; + }, + presence: async (channel) => (client === null ? [] : client.presence(channel)), + publish: async (channel, data) => { + await client?.publish(channel, data); + }, + }; + }, [state]); + + return {children}; +}; + +export const useRealtime = (): RealtimeContextValue => { + const context = useContext(RealtimeContext); + if (context === null) { + throw new Error('useRealtime must be used within a RealtimeProvider'); + } + return context; +}; diff --git a/packages/mobile/src/ws/index.ts b/packages/mobile/src/ws/index.ts new file mode 100644 index 0000000..dbe7668 --- /dev/null +++ b/packages/mobile/src/ws/index.ts @@ -0,0 +1 @@ +export { RealtimeProvider, useRealtime } from './RealtimeProvider'; diff --git a/packages/mobile/tsconfig.json b/packages/mobile/tsconfig.json index da8829f..3206be9 100644 --- a/packages/mobile/tsconfig.json +++ b/packages/mobile/tsconfig.json @@ -1,7 +1,33 @@ { - "extends": "../../tsconfig.base.json", + "extends": "expo/tsconfig.base", "compilerOptions": { - "rootDir": "src" + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true, + "useUnknownInCatchVariables": true, + "allowUnusedLabels": false, + "allowUnreachableCode": false, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "noEmit": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "moduleResolution": "Bundler", + "paths": { + "@/*": ["./src/*"] + } }, - "include": ["src"] + "include": [ + "app/**/*", + "src/**/*", + "app.config.ts", + ".expo/types/**/*.ts", + "expo-env.d.ts" + ] } diff --git a/packages/web/package.json b/packages/web/package.json index 5c5b255..7bdc0bb 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -12,6 +12,9 @@ }, "dependencies": { "@altricade/core": "workspace:^", + "@ffmpeg/core": "^0.12.10", + "@ffmpeg/ffmpeg": "^0.12.15", + "@ffmpeg/util": "^0.12.2", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/packages/web/src/app/App.tsx b/packages/web/src/app/App.tsx index d298a8e..f4943fd 100644 --- a/packages/web/src/app/App.tsx +++ b/packages/web/src/app/App.tsx @@ -101,6 +101,7 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re const { conversations, onlineMap, + typingMap, startDirect, createGroupChat, createChannelChat, @@ -301,6 +302,8 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re conversations={conversations} currentId={current?.id ?? null} onlineMap={onlineMap} + typingMap={typingMap} + meId={user.id} folders={folders} activeFolderId={activeFolderId} onSelectFolder={setActiveFolderId} diff --git a/packages/web/src/app/index.css b/packages/web/src/app/index.css index e0f36c7..0d4be46 100644 --- a/packages/web/src/app/index.css +++ b/packages/web/src/app/index.css @@ -3787,3 +3787,9 @@ button.set-row:hover { background: rgb(0 0 0 / 40%); border-radius: 50%; } + +/* Chat-list "typing…" preview (replaces the last-message line while active). */ +.conv-typing { + color: var(--color-accent, #6366f1); + font-style: italic; +} diff --git a/packages/web/src/features/conversations/model.ts b/packages/web/src/features/conversations/model.ts index 1dcf7be..9c9384a 100644 --- a/packages/web/src/features/conversations/model.ts +++ b/packages/web/src/features/conversations/model.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import type { Conversation, Message } from '@altricade/core'; -import { conversationChannel, userChannel, EventType } from '@altricade/core'; +import type { Conversation, LastMessagePreview, Message, TypingEvent } from '@altricade/core'; +import { conversationChannel, ephemeralChannel, userChannel, EventType } from '@altricade/core'; import { listConversations, createDirect, @@ -16,6 +16,8 @@ export interface UseConversations { conversations: Conversation[]; loading: boolean; onlineMap: Record; + /** Conversation id → ids of members typing right now (self excluded). */ + typingMap: Record; startDirect: (username: string) => Promise; createGroupChat: (title: string, members: string[]) => Promise; createChannelChat: ( @@ -50,6 +52,17 @@ const isConversationCleared = ( ): data is { type: 'conversation.cleared'; conversationId: string } => hasType(data) && data.type === EventType.ConversationCleared && 'conversationId' in data; +const isTypingEvent = (data: unknown): data is TypingEvent => + hasType(data) && (data.type === EventType.TypingStart || data.type === EventType.TypingStop); + +const previewOf = (message: Message): LastMessagePreview => ({ + senderId: message.senderId, + senderName: message.sender.displayName, + content: message.deletedAt !== null ? '' : message.content, + mediaKind: message.deletedAt !== null ? null : (message.media?.kind ?? null), + deleted: message.deletedAt !== null, +}); + const upsert = (list: Conversation[], conversation: Conversation): Conversation[] => { const rest = list.filter((item) => item.id !== conversation.id); return [conversation, ...rest]; @@ -83,14 +96,14 @@ export const useConversations = ( setOnlineMap(online); }, []); + // Every new message — mine included — bumps the row to the top and refreshes + // its preview; only foreign messages count as unread / raise a toast. const bumpUnread = useCallback( (message: Message): void => { - if (message.senderId === userId) { - return; - } + const own = message.senderId === userId; const isCurrent = currentIdRef.current === message.conversationId; // Online = app open, so surface an in-app toast for messages in other chats. - if (!isCurrent) { + if (!own && !isCurrent) { onIncomingRef.current?.(message); } setConversations((prev) => @@ -102,7 +115,8 @@ export const useConversations = ( return { ...c, lastMessageAt: message.createdAt, - unreadCount: isCurrent ? 0 : c.unreadCount + 1, + lastMessage: previewOf(message), + unreadCount: isCurrent ? 0 : own ? c.unreadCount : c.unreadCount + 1, }; }) .sort(byRecency), @@ -111,6 +125,68 @@ export const useConversations = ( [userId], ); + // ---- typing indicators for the chat list (ephemeral, self-expiring) ---- + + const [typingMap, setTypingMap] = useState>({}); + const typingTimers = useRef>>(new Map()); + + const clearTyping = useCallback((conversationId: string, uid: string): void => { + setTypingMap((prev) => { + const uids = prev[conversationId] ?? []; + if (!uids.includes(uid)) { + return prev; + } + const rest = uids.filter((existing) => existing !== uid); + if (rest.length === 0) { + return Object.fromEntries(Object.entries(prev).filter(([key]) => key !== conversationId)); + } + return { ...prev, [conversationId]: rest }; + }); + }, []); + + const handleTyping = useCallback( + (event: TypingEvent): void => { + if (event.userId === userId) { + return; + } + const key = `${event.conversationId}:${event.userId}`; + const pending = typingTimers.current.get(key); + if (pending !== undefined) { + clearTimeout(pending); + typingTimers.current.delete(key); + } + if (event.type === EventType.TypingStop) { + clearTyping(event.conversationId, event.userId); + return; + } + setTypingMap((prev) => { + const uids = prev[event.conversationId] ?? []; + return uids.includes(event.userId) + ? prev + : { ...prev, [event.conversationId]: [...uids, event.userId] }; + }); + // Start events renew every ~2.5s while typing; expire shortly after they stop. + typingTimers.current.set( + key, + setTimeout(() => { + typingTimers.current.delete(key); + clearTyping(event.conversationId, event.userId); + }, 4000), + ); + }, + [userId, clearTyping], + ); + + useEffect(() => { + const timers = typingTimers.current; + return () => { + for (const timer of timers.values()) { + clearTimeout(timer); + } + timers.clear(); + }; + }, []); + useEffect(() => { let cancelled = false; const load = async (): Promise => { @@ -170,6 +246,13 @@ export const useConversations = ( } }), ); + cleanups.push( + subscribe(ephemeralChannel(id), (event) => { + if (isTypingEvent(event.data)) { + handleTyping(event.data); + } + }), + ); cleanups.push( onPresence(channel, (action, uid) => { const set = presentByChannel.current.get(channel) ?? new Set(); @@ -192,7 +275,7 @@ export const useConversations = ( cleanup(); } }; - }, [convKey, subscribe, onPresence, presence, recomputeOnline, bumpUnread]); + }, [convKey, subscribe, onPresence, presence, recomputeOnline, bumpUnread, handleTyping]); // Opening a conversation clears its unread badge immediately (markRead persists it). useEffect(() => { @@ -248,6 +331,7 @@ export const useConversations = ( conversations, loading, onlineMap, + typingMap, startDirect, createGroupChat, createChannelChat, diff --git a/packages/web/src/features/conversations/ui/ConversationSidebar.tsx b/packages/web/src/features/conversations/ui/ConversationSidebar.tsx index f7030ee..890b505 100644 --- a/packages/web/src/features/conversations/ui/ConversationSidebar.tsx +++ b/packages/web/src/features/conversations/ui/ConversationSidebar.tsx @@ -29,6 +29,10 @@ interface Props { conversations: Conversation[]; currentId: string | null; onlineMap: Record; + /** Conversation id → members typing right now (drives the "typing…" preview). */ + typingMap: Record; + /** The signed-in user's id ("You:" prefix on own last messages). */ + meId: string; folders: UseFolders; activeFolderId: string | null; onSelectFolder: (folderId: string | null) => void; @@ -52,14 +56,46 @@ const title = (conversation: Conversation): string => { return conversation.peer === null ? 'Direct' : conversation.peer.displayName; }; -const subtitle = (conversation: Conversation): string => { - if (conversation.type === 'channel') { - return 'Channel'; +const mediaLabel = (kind: NonNullable['mediaKind']): string => { + switch (kind) { + case 'image': + return 'Photo'; + case 'video': + return 'Video'; + case 'video_note': + return 'Video message'; + case 'voice': + return 'Voice message'; + default: + return 'File'; } - if (conversation.type === 'group') { - return 'Group'; +}; + +// Telegram-style row preview: the last message ("You:" for own sends, sender +// name in groups), falling back to the chat kind for an empty conversation. +const subtitle = (conversation: Conversation, meId: string): string => { + const last = conversation.lastMessage; + if (last === null) { + return conversation.type === 'channel' + ? 'Channel' + : conversation.type === 'group' + ? 'Group' + : ''; } - return conversation.peer === null ? '' : `@${conversation.peer.username}`; + const body = last.deleted + ? 'Message deleted' + : last.content.length > 0 + ? last.content + : last.mediaKind !== null + ? mediaLabel(last.mediaKind) + : ''; + const prefix = + last.senderId === meId + ? 'You: ' + : conversation.type === 'group' + ? `${last.senderName}: ` + : ''; + return `${prefix}${body}`; }; const initial = (conversation: Conversation): string => { @@ -170,6 +206,8 @@ export const ConversationSidebar = ({ conversations, currentId, onlineMap, + typingMap, + meId, folders, activeFolderId, onSelectFolder, @@ -355,6 +393,7 @@ export const ConversationSidebar = ({ conversation.peer !== null && onlineMap[conversation.peer.id] === true; const active = conversation.id === currentId; const pinned = folders.isPinned(conversation.id, activeFolderId); + const typing = (typingMap[conversation.id] ?? []).length > 0; return (