This commit is contained in:
Заид Омар Медхат | Zaid Omar Medhat 2026-07-13 12:32:00 +05:00
parent f5b620111b
commit f653b083aa
139 changed files with 16867 additions and 156 deletions

View file

@ -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://<LAN-IP>: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

View file

@ -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.

View file

@ -99,7 +99,7 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
const contactsRepository = createContactsRepository(app.db);
const deliver = createDeliver(conversationsRepository, publish);
const mediaService = createMediaService({
minio: app.minioPublic,
presignClient: app.minioPresign,
mediaBucket: config.minio.buckets.media,
avatarsBucket: config.minio.buckets.avatars,
publicUrl: config.minio.publicUrl,
@ -157,7 +157,7 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
conversations: conversationsRepository,
deliver,
publish,
mediaDownloadUrl: (objectKey) => mediaService.downloadUrl(objectKey),
mediaDownloadUrl: (objectKey, origin) => mediaService.downloadUrl(objectKey, origin),
notify: (message) => {
void notificationQueue
.enqueueMessage({

View file

@ -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<void> => {
},
async (request, reply) => {
const issued = await app.authService.register(request.body, context(request));
const tokenMode = isTokenMode(request);
if (!tokenMode) {
setRefreshCookie(reply, issued.refreshToken);
return reply.code(201).send(publicResult(issued));
}
return reply.code(201).send(authResult(issued, tokenMode));
},
);
@ -76,37 +101,54 @@ export const authRoutes = (app: FastifyInstance): Promise<void> => {
},
async (request, reply) => {
const issued = await app.authService.login(request.body, context(request));
const tokenMode = isTokenMode(request);
if (!tokenMode) {
setRefreshCookie(reply, issued.refreshToken);
return reply.send(publicResult(issued));
}
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));
if (!tokenMode) {
setRefreshCookie(reply, issued.refreshToken);
return reply.send(publicResult(issued));
}
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);
}

View file

@ -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,
});

View file

@ -4,6 +4,16 @@ import type { Database, ConversationsTable } from '../../db/schema';
export type ConversationRow = Selectable<ConversationsTable>;
// List rows carry the newest message visible to the requesting user (chat-list
// preview); all fields are null for an empty (or fully cleared) conversation.
export interface ConversationListRow extends ConversationRow {
last_msg_sender_id: string | null;
last_msg_sender_name: string | null;
last_msg_content: string | null;
last_msg_media_kind: string | null;
last_msg_deleted_at: Date | null;
}
export interface MemberWithUser {
user_id: string;
role: string;
@ -35,7 +45,7 @@ export interface ConversationsRepository {
): Promise<ConversationRow>;
findOrCreateDirect(userA: string, userB: string): Promise<FindOrCreate>;
findById(id: string): Promise<ConversationRow | undefined>;
listForUser(userId: string): Promise<ConversationRow[]>;
listForUser(userId: string): Promise<ConversationListRow[]>;
peersForDirect(userId: string, conversationIds: string[]): Promise<Map<string, MemberWithUser>>;
getPeer(conversationId: string, userId: string): Promise<MemberWithUser | undefined>;
isMember(conversationId: string, userId: string): Promise<boolean>;
@ -206,7 +216,49 @@ export const createConversationsRepository = (db: Kysely<Database>): 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<string | null>('nullif', [sql`messages.media_meta->>'kind'`, sql`''`])
.as('last_msg_media_kind'),
'messages.deleted_at as last_msg_deleted_at',
])
.orderBy('messages.seq', 'desc')
.limit(1)
.as('last_msg'),
(join) => join.onTrue(),
)
.selectAll('conversations')
.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(),

View file

@ -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,
);
});
},

View file

@ -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<void> => {
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<void> => {
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);
},
);

View file

@ -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<UploadTarget>;
createAvatarUploadUrl(userId: string, mime: string): Promise<AvatarTarget>;
downloadUrl(objectKey: string): Promise<string>;
createUploadUrl(
userId: string,
kind: MediaKind,
mime: string,
size: number,
origin: string | null,
): Promise<UploadTarget>;
createAvatarUploadUrl(userId: string, mime: string, origin: string | null): Promise<AvatarTarget>;
downloadUrl(objectKey: string, origin: string | null): Promise<string>;
avatarPublicUrl(objectKey: string): string;
}
@ -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}`,
});

View file

@ -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<void> => {
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 });
},
);

View file

@ -25,7 +25,7 @@ export interface MessagesServiceDeps {
deliver: Deliver;
/** Personal-channel publisher for per-user view-state events. */
publish: Publisher;
mediaDownloadUrl: (objectKey: string) => Promise<string>;
mediaDownloadUrl: (objectKey: string, origin: string | null) => Promise<string>;
/** 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<void>;
mediaUrl(conversationId: string, messageId: string, userId: string): Promise<string>;
mediaUrl(
conversationId: string,
messageId: string,
userId: string,
origin: string | null,
): Promise<string>;
}
export const createMessagesService = (deps: MessagesServiceDeps): MessagesService => {
@ -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);
},
};
};

View file

@ -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({
// 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<string, Client>();
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: publicPort,
useSSL: publicSecure,
port: parsedPort,
useSSL: secure,
accessKey,
secretKey,
region,
});
app.decorate('minioPublic', publicClient);
presignClients.set(base, created);
return created;
};
app.decorate('minioPresign', (origin: string | null) => clientFor(origin ?? publicUrl));
return Promise.resolve();
},

View file

@ -0,0 +1,15 @@
import type { FastifyRequest } from 'fastify';
// The public origin the client used to reach the gateway (nginx forwards the
// original Host verbatim and stamps X-Forwarded-Proto). Presigning media URLs
// for THIS origin is what lets one backend serve browsers on localhost and
// phones on a LAN IP simultaneously. Null when the request didn't come through
// the proxy (direct dev access) — callers fall back to the configured URL.
export const requestOrigin = (request: FastifyRequest): string | null => {
const proto = request.headers['x-forwarded-proto'];
const host = request.headers.host;
if (typeof proto === 'string' && proto.length > 0 && typeof host === 'string' && host.length > 0) {
return `${proto.split(',')[0] ?? proto}://${host}`;
}
return null;
};

View file

@ -22,16 +22,24 @@ export const register = async (config: ApiClientConfig, body: RegisterBody): Pro
export const login = async (config: ApiClientConfig, body: LoginBody): Promise<AuthResult> =>
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<AuthResult> | null = null;
const refreshBody = (config: ApiClientConfig): { refreshToken: string } | undefined => {
if (config.authMode !== 'token') {
return undefined;
}
const token = config.getRefreshToken?.() ?? null;
return token === null ? undefined : { refreshToken: token };
};
export const refresh = (config: ApiClientConfig): Promise<AuthResult> => {
inflightRefresh ??= (async () => {
try {
return parse(authResultV, await requestJson(config, 'POST', '/auth/refresh'));
return parse(authResultV, await requestJson(config, 'POST', '/auth/refresh', refreshBody(config)));
} finally {
inflightRefresh = null;
}
@ -40,7 +48,7 @@ export const refresh = (config: ApiClientConfig): Promise<AuthResult> => {
};
export const logout = async (config: ApiClientConfig): Promise<void> => {
await requestJson(config, 'POST', '/auth/logout');
await requestJson(config, 'POST', '/auth/logout', refreshBody(config));
};
export const logoutAll = async (config: ApiClientConfig): Promise<void> => {

View file

@ -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<unknown> => {
const tokenMode = config.authMode === 'token';
const headers: Record<string, string> = { 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),
});

View file

@ -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,

View file

@ -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<PublicUser>(publicUserSchema);
const publicUserListV = compileValidator<PublicUser[]>(publicUserListSchema);
const userV = compileValidator<User>(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<PublicUser> =>
parse(publicUserV, await requestJson(config, 'GET', `/users/${encodeURIComponent(username)}`));
export const setAvatar = async (config: ApiClientConfig, body: SetAvatarBody): Promise<User> =>
parse(userV, await requestJson(config, 'POST', '/me/avatar', body));

View file

@ -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;

View file

@ -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.

View file

@ -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;
}

View file

@ -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';

6
packages/mobile/.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli
expo-env.d.ts
# @end expo-cli

67
packages/mobile/README.md Normal file
View file

@ -0,0 +1,67 @@
# Zovi Mobile (Expo)
React Native + Expo (SDK 54) client for Altricade, consuming the shared
`@altricade/core` package as source. FSD layout: `app/` holds thin expo-router
route files only; all logic and UI live under `src/`.
## Prerequisites
- The backend + infra stack running (Postgres, Redis, MinIO, Centrifugo) — see
`infra/docker-compose.yml`. Start the Fastify backend so the API is on
`http://localhost:8080`.
- On a physical device the API/WS host must be reachable from the phone, so
point the client at your machine's LAN IP (see env below), not `localhost`.
## Run
```bash
pnpm install # from the repo root
# From packages/mobile:
pnpm start # Metro + dev menu
pnpm ios # build & run iOS (needs a dev build)
pnpm android # build & run Android (needs a dev build)
```
This app uses native modules (reanimated, gesture-handler, camera, audio,
video, secure-store, notifications), so it requires a **development build**
(`expo-dev-client`) — it will not run in Expo Go. Build one with EAS or
`expo run:ios` / `expo run:android`.
### Environment
Public config is read from `app.config.ts` `extra`, driven by `EXPO_PUBLIC_*`:
| Variable | Default | Purpose |
| ------------------------- | -------------------------------------------------- | -------------------------------- |
| `EXPO_PUBLIC_API_URL` | `http://localhost:8080/api` | REST base URL |
| `EXPO_PUBLIC_WS_URL` | `ws://localhost:8080/connection/websocket` | Centrifugo websocket |
| `EXPO_PUBLIC_PUSH_ENABLED`| unset (off) | Enable native push registration |
Example for a device on your LAN:
```bash
EXPO_PUBLIC_API_URL=http://192.168.1.20:8080/api \
EXPO_PUBLIC_WS_URL=ws://192.168.1.20:8080/connection/websocket \
pnpm start
```
## Quality gates
```bash
pnpm typecheck # tsc --noEmit (strict, no any/assertions/!)
pnpm lint # eslint
```
## Auth transport
Native uses token-mode auth: the refresh token is stored in `expo-secure-store`
(Keychain/Keystore) and sent in the request body with an `X-Auth-Mode: token`
header, instead of the web's httpOnly cookie. The access token stays in memory.
## Push notifications
Native push (FCM/APNs) needs a build with push credentials and cannot run in
Expo Go, so registration is gated behind `EXPO_PUBLIC_PUSH_ENABLED`. Tap-to-open
deep-linking (`src/services/usePush.ts`) is always wired, so once credentials
are configured, flip the flag and notifications route into the right chat.

View file

@ -0,0 +1,46 @@
import type { ExpoConfig } from 'expo/config';
// Public runtime config only — real secrets stay server-side. The API/WS base
// URLs point at the same gateway the web client uses; override per environment
// with EXPO_PUBLIC_API_URL / EXPO_PUBLIC_WS_URL (e.g. a LAN IP for a device).
const apiUrl = process.env['EXPO_PUBLIC_API_URL'] ?? 'http://localhost:8080/api';
const wsUrl = process.env['EXPO_PUBLIC_WS_URL'] ?? 'ws://localhost:8080/connection/websocket';
const config: ExpoConfig = {
name: 'Zovi',
slug: 'zovi',
scheme: 'zovi',
version: '0.1.0',
orientation: 'portrait',
userInterfaceStyle: 'automatic',
newArchEnabled: true,
ios: {
supportsTablet: true,
bundleIdentifier: 'com.altricade.messenger',
},
android: {
package: 'com.altricade.messenger',
edgeToEdgeEnabled: true,
},
plugins: [
'expo-router',
'expo-secure-store',
['expo-audio', { microphonePermission: 'Zovi uses the microphone to record voice messages.' }],
'expo-video',
['expo-camera', { cameraPermission: 'Zovi uses the camera for video messages.' }],
[
'expo-image-picker',
{ photosPermission: 'Zovi accesses your photos to share images and videos.' },
],
'expo-notifications',
],
experiments: {
typedRoutes: true,
},
extra: {
apiUrl,
wsUrl,
},
};
export default config;

View file

@ -0,0 +1,46 @@
import type { ReactElement } from 'react';
import { Tabs } from 'expo-router';
import { Icon } from '@/components';
import type { IconName } from '@/components';
import { useTheme } from '@/theme';
// Open on Chats, not the leftmost (Contacts) tab.
export const unstable_settings = {
initialRouteName: 'index',
};
const tabIcon =
(name: IconName) =>
({ color, size }: { color: string; size: number }): ReactElement => (
<Icon name={name} size={size} color={color} />
);
// Persistent bottom navigation for the authenticated area: Contacts · Chats ·
// Settings. Chat/profile/group and modals live in the parent stack so they
// cover the tab bar when opened.
export default function TabsLayout(): ReactElement {
const { colors } = useTheme();
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: colors.accent,
tabBarInactiveTintColor: colors.textMuted,
tabBarStyle: {
backgroundColor: colors.background,
borderTopColor: colors.border,
},
}}
>
<Tabs.Screen
name="contacts"
options={{ title: 'Contacts', tabBarIcon: tabIcon('user') }}
/>
<Tabs.Screen name="index" options={{ title: 'Chats', tabBarIcon: tabIcon('chats') }} />
<Tabs.Screen
name="settings"
options={{ title: 'Settings', tabBarIcon: tabIcon('settings') }}
/>
</Tabs>
);
}

View file

@ -0,0 +1,6 @@
import type { ReactElement } from 'react';
import { ContactsScreen } from '@/features/contacts';
export default function ContactsRoute(): ReactElement {
return <ContactsScreen />;
}

View file

@ -0,0 +1,6 @@
import type { ReactElement } from 'react';
import { ChatsScreen } from '@/features/conversations';
export default function ChatsRoute(): ReactElement {
return <ChatsScreen />;
}

View file

@ -0,0 +1,6 @@
import type { ReactElement } from 'react';
import { SettingsScreen } from '@/features/settings';
export default function SettingsRoute(): ReactElement {
return <SettingsScreen />;
}

View file

@ -0,0 +1,29 @@
import type { ReactElement } from 'react';
import { Stack } from 'expo-router';
import { RealtimeProvider } from '@/ws';
import { useTheme } from '@/theme';
import { usePush } from '@/services/usePush';
// Authenticated area: everything here has a live realtime connection.
export default function AppLayout(): ReactElement {
const { colors } = useTheme();
usePush();
return (
<RealtimeProvider>
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.background },
}}
>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="chat/[id]" />
<Stack.Screen name="profile/[username]" options={{ presentation: 'card' }} />
<Stack.Screen name="group/[id]" options={{ presentation: 'card' }} />
<Stack.Screen name="new/group" options={{ presentation: 'modal' }} />
<Stack.Screen name="new/channel" options={{ presentation: 'modal' }} />
<Stack.Screen name="forward" options={{ presentation: 'modal' }} />
</Stack>
</RealtimeProvider>
);
}

View file

@ -0,0 +1,12 @@
import type { ReactElement } from 'react';
import { useLocalSearchParams } from 'expo-router';
import { ChatScreen } from '@/features/messaging';
export default function ChatRoute(): ReactElement | null {
const params = useLocalSearchParams<{ id: string }>();
const id = typeof params.id === 'string' ? params.id : null;
if (id === null) {
return null;
}
return <ChatScreen conversationId={id} />;
}

View file

@ -0,0 +1,14 @@
import type { ReactElement } from 'react';
import { useLocalSearchParams } from 'expo-router';
import { ForwardScreen } from '@/features/messaging';
export default function ForwardRoute(): ReactElement | null {
const params = useLocalSearchParams<{ ids: string; from: string }>();
const idsParam = typeof params.ids === 'string' ? params.ids : '';
const from = typeof params.from === 'string' ? params.from : null;
const messageIds = idsParam.split(',').filter((id) => id.length > 0);
if (from === null || messageIds.length === 0) {
return null;
}
return <ForwardScreen messageIds={messageIds} fromConversationId={from} />;
}

View file

@ -0,0 +1,12 @@
import type { ReactElement } from 'react';
import { useLocalSearchParams } from 'expo-router';
import { GroupInfoScreen } from '@/features/profile';
export default function GroupInfoRoute(): ReactElement | null {
const params = useLocalSearchParams<{ id: string }>();
const id = typeof params.id === 'string' ? params.id : null;
if (id === null) {
return null;
}
return <GroupInfoScreen conversationId={id} />;
}

View file

@ -0,0 +1,6 @@
import type { ReactElement } from 'react';
import { NewChatWizard } from '@/features/conversations';
export default function NewChannelRoute(): ReactElement {
return <NewChatWizard mode="channel" />;
}

View file

@ -0,0 +1,6 @@
import type { ReactElement } from 'react';
import { NewChatWizard } from '@/features/conversations';
export default function NewGroupRoute(): ReactElement {
return <NewChatWizard mode="group" />;
}

View file

@ -0,0 +1,12 @@
import type { ReactElement } from 'react';
import { useLocalSearchParams } from 'expo-router';
import { ProfileScreen } from '@/features/profile';
export default function ProfileRoute(): ReactElement | null {
const params = useLocalSearchParams<{ username: string }>();
const username = typeof params.username === 'string' ? params.username : null;
if (username === null || username.length === 0) {
return null;
}
return <ProfileScreen username={username} />;
}

View file

@ -0,0 +1,6 @@
import type { ReactElement } from 'react';
import { Stack } from 'expo-router';
export default function AuthLayout(): ReactElement {
return <Stack screenOptions={{ headerShown: false }} />;
}

View file

@ -0,0 +1,6 @@
import type { ReactElement } from 'react';
import { SignInScreen } from '@/features/auth';
export default function SignIn(): ReactElement {
return <SignInScreen />;
}

View file

@ -0,0 +1,65 @@
import 'react-native-gesture-handler';
import { useEffect } from 'react';
import type { ReactElement } from 'react';
import { ActivityIndicator, View } from 'react-native';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { KeyboardProvider } from 'react-native-keyboard-controller';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { setAudioModeAsync } from 'expo-audio';
import { ThemeProvider, useTheme } from '@/theme';
import { useSession } from '@/stores/session';
import { ErrorBoundary } from '@/components';
// Voice notes must play even with the iPhone mute switch on (Telegram
// behavior) — without this, playback "works" but is silent on most devices.
void setAudioModeAsync({ playsInSilentMode: true });
const RootNavigator = (): ReactElement => {
const { colors, name } = useTheme();
const status = useSession((s) => s.status);
const bootstrap = useSession((s) => s.bootstrap);
useEffect(() => {
void bootstrap();
}, [bootstrap]);
if (status === 'loading') {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.background }}>
<ActivityIndicator color={colors.accent} />
</View>
);
}
return (
<>
<StatusBar style={name === 'dark' ? 'light' : 'dark'} />
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.background } }}>
<Stack.Protected guard={status === 'authenticated'}>
<Stack.Screen name="(app)" />
</Stack.Protected>
<Stack.Protected guard={status !== 'authenticated'}>
<Stack.Screen name="(auth)" />
</Stack.Protected>
</Stack>
</>
);
};
export default function RootLayout(): ReactElement {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<ErrorBoundary>
<SafeAreaProvider>
<KeyboardProvider>
<ThemeProvider>
<RootNavigator />
</ThemeProvider>
</KeyboardProvider>
</SafeAreaProvider>
</ErrorBoundary>
</GestureHandlerRootView>
);
}

View file

@ -0,0 +1,8 @@
// Expo + Reanimated. The worklets plugin (Reanimated 4) MUST be listed last.
module.exports = function babel(api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ['react-native-worklets/plugin'],
};
};

View file

@ -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',
},
},
];

3
packages/mobile/expo-env.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
/// <reference types="expo/types" />
// NOTE: This file should not be edited and should be in your git ignore

30
packages/mobile/ios/.gitignore vendored Normal file
View file

@ -0,0 +1,30 @@
# OSX
#
.DS_Store
# Xcode
#
build/
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
xcuserdata
*.xccheckout
*.moved-aside
DerivedData
*.hmap
*.ipa
*.xcuserstate
project.xcworkspace
.xcode.env.local
# Bundle artifacts
*.jsbundle
# CocoaPods
/Pods/

View file

@ -0,0 +1,11 @@
# This `.xcode.env` file is versioned and is used to source the environment
# used when running script phases inside Xcode.
# To customize your local environment, you can create an `.xcode.env.local`
# file that is not versioned.
# NODE_BINARY variable contains the PATH to the node executable.
#
# Customize the NODE_BINARY variable here.
# For example, to use nvm with brew, add the following line
# . "$(brew --prefix nvm)/nvm.sh" --no-use
export NODE_BINARY=$(command -v node)

View file

@ -0,0 +1,63 @@
require File.join(File.dirname(`node --print "require.resolve('expo/package.json')"`), "scripts/autolinking")
require File.join(File.dirname(`node --print "require.resolve('react-native/package.json')"`), "scripts/react_native_pods")
require 'json'
podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {}
def ccache_enabled?(podfile_properties)
# Environment variable takes precedence
return ENV['USE_CCACHE'] == '1' if ENV['USE_CCACHE']
# Fall back to Podfile properties
podfile_properties['apple.ccacheEnabled'] == 'true'
end
ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if podfile_properties['newArchEnabled'] == 'false'
ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] ||= podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR']
ENV['RCT_USE_RN_DEP'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' && podfile_properties['newArchEnabled'] != 'false'
ENV['RCT_USE_PREBUILT_RNCORE'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' && podfile_properties['newArchEnabled'] != 'false'
platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1'
prepare_react_native_project!
target 'Zovi' do
use_expo_modules!
if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] == '1'
config_command = ['node', '-e', "process.argv=['', '', 'config'];require('@react-native-community/cli').run()"];
else
config_command = [
'node',
'--no-warnings',
'--eval',
'require(\'expo/bin/autolinking\')',
'expo-modules-autolinking',
'react-native-config',
'--json',
'--platform',
'ios'
]
end
config = use_native_modules!(config_command)
use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks']
use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS']
use_react_native!(
:path => config[:reactNativePath],
:hermes_enabled => podfile_properties['expo.jsEngine'] == nil || podfile_properties['expo.jsEngine'] == 'hermes',
# An absolute path to your application root.
:app_path => "#{Pod::Config.instance.installation_root}/..",
:privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false',
)
post_install do |installer|
react_native_post_install(
installer,
config[:reactNativePath],
:mac_catalyst_enabled => false,
:ccache_enabled => ccache_enabled?(podfile_properties),
)
end
end

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,5 @@
{
"expo.jsEngine": "hermes",
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
"newArchEnabled": "true"
}

View file

@ -0,0 +1,560 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
2600D210A644F3099EBCAC60 /* libPods-Zovi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 2F35A3DE0EDD554ECC9665C9 /* libPods-Zovi.a */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
B1BC7EA401DEA33ACBA14386 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 5E79EB555E8560CE37666A91 /* PrivacyInfo.xcprivacy */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
E4F7C222FFFCC78DE13D287F /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5206ED93486F6EE25D62DF8 /* ExpoModulesProvider.swift */; };
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
01B1386BDA70C35F3BB3A395 /* Pods-Zovi.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Zovi.debug.xcconfig"; path = "Target Support Files/Pods-Zovi/Pods-Zovi.debug.xcconfig"; sourceTree = "<group>"; };
13B07F961A680F5B00A75B9A /* Zovi.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Zovi.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Zovi/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Zovi/Info.plist; sourceTree = "<group>"; };
2F35A3DE0EDD554ECC9665C9 /* libPods-Zovi.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Zovi.a"; sourceTree = BUILT_PRODUCTS_DIR; };
5E79EB555E8560CE37666A91 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Zovi/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
A5206ED93486F6EE25D62DF8 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Zovi/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Zovi/SplashScreen.storyboard; sourceTree = "<group>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
DE9441A6824A495DF6F5687D /* Pods-Zovi.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Zovi.release.xcconfig"; path = "Target Support Files/Pods-Zovi/Pods-Zovi.release.xcconfig"; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Zovi/AppDelegate.swift; sourceTree = "<group>"; };
F11748442D0722820044C1D9 /* Zovi-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "Zovi-Bridging-Header.h"; path = "Zovi/Zovi-Bridging-Header.h"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
13B07F8C1A680F5B00A75B9A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
2600D210A644F3099EBCAC60 /* libPods-Zovi.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
13B07FAE1A68108700A75B9A /* Zovi */ = {
isa = PBXGroup;
children = (
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
F11748442D0722820044C1D9 /* Zovi-Bridging-Header.h */,
BB2F792B24A3F905000567C9 /* Supporting */,
13B07FB51A68108700A75B9A /* Images.xcassets */,
13B07FB61A68108700A75B9A /* Info.plist */,
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
5E79EB555E8560CE37666A91 /* PrivacyInfo.xcprivacy */,
);
name = Zovi;
sourceTree = "<group>";
};
2D16E6871FA4F8E400B85C8A /* Frameworks */ = {
isa = PBXGroup;
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
2F35A3DE0EDD554ECC9665C9 /* libPods-Zovi.a */,
);
name = Frameworks;
sourceTree = "<group>";
};
68802507543A031D2F8E62A0 /* Pods */ = {
isa = PBXGroup;
children = (
01B1386BDA70C35F3BB3A395 /* Pods-Zovi.debug.xcconfig */,
DE9441A6824A495DF6F5687D /* Pods-Zovi.release.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
832341AE1AAA6A7D00B99B32 /* Libraries */ = {
isa = PBXGroup;
children = (
);
name = Libraries;
sourceTree = "<group>";
};
83CBB9F61A601CBA00E9B192 = {
isa = PBXGroup;
children = (
13B07FAE1A68108700A75B9A /* Zovi */,
832341AE1AAA6A7D00B99B32 /* Libraries */,
83CBBA001A601CBA00E9B192 /* Products */,
2D16E6871FA4F8E400B85C8A /* Frameworks */,
68802507543A031D2F8E62A0 /* Pods */,
A06D5BE166B78DD90D60B1AC /* ExpoModulesProviders */,
);
indentWidth = 2;
sourceTree = "<group>";
tabWidth = 2;
usesTabs = 0;
};
83CBBA001A601CBA00E9B192 /* Products */ = {
isa = PBXGroup;
children = (
13B07F961A680F5B00A75B9A /* Zovi.app */,
);
name = Products;
sourceTree = "<group>";
};
A06D5BE166B78DD90D60B1AC /* ExpoModulesProviders */ = {
isa = PBXGroup;
children = (
C497067B79F68A595CE42947 /* Zovi */,
);
name = ExpoModulesProviders;
sourceTree = "<group>";
};
BB2F792B24A3F905000567C9 /* Supporting */ = {
isa = PBXGroup;
children = (
BB2F792C24A3F905000567C9 /* Expo.plist */,
);
name = Supporting;
path = Zovi/Supporting;
sourceTree = "<group>";
};
C497067B79F68A595CE42947 /* Zovi */ = {
isa = PBXGroup;
children = (
A5206ED93486F6EE25D62DF8 /* ExpoModulesProvider.swift */,
);
name = Zovi;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
13B07F861A680F5B00A75B9A /* Zovi */ = {
isa = PBXNativeTarget;
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Zovi" */;
buildPhases = (
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
C7038DBFE2B5A25EBB692046 /* [Expo] Configure project */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
B720D6E7DDBC52B4CBBC0DB6 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = Zovi;
productName = Zovi;
productReference = 13B07F961A680F5B00A75B9A /* Zovi.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
83CBB9F71A601CBA00E9B192 /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1130;
TargetAttributes = {
13B07F861A680F5B00A75B9A = {
DevelopmentTeam = 75MMB3DXA8;
LastSwiftMigration = 1250;
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Zovi" */;
compatibilityVersion = "Xcode 3.2";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 83CBB9F61A601CBA00E9B192;
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
13B07F861A680F5B00A75B9A /* Zovi */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
13B07F8E1A680F5B00A75B9A /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
B1BC7EA401DEA33ACBA14386 /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"$(SRCROOT)/.xcode.env",
"$(SRCROOT)/.xcode.env.local",
);
name = "Bundle React Native code and images";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n";
};
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Zovi-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Zovi/Pods-Zovi-resources.sh",
"${PODS_CONFIGURATION_BUILD_DIR}/EXApplication/ExpoApplication_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle",
);
name = "[CP] Copy Pods Resources";
outputPaths = (
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoApplication_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Zovi/Pods-Zovi-resources.sh\"\n";
showEnvVarsInLog = 0;
};
B720D6E7DDBC52B4CBBC0DB6 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Zovi/Pods-Zovi-frameworks.sh",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Zovi/Pods-Zovi-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
C7038DBFE2B5A25EBB692046 /* [Expo] Configure project */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"$(SRCROOT)/.xcode.env",
"$(SRCROOT)/.xcode.env.local",
"$(SRCROOT)/Zovi/Zovi.entitlements",
"$(SRCROOT)/Pods/Target Support Files/Pods-Zovi/expo-configure-project.sh",
);
name = "[Expo] Configure project";
outputFileListPaths = (
);
outputPaths = (
"$(SRCROOT)/Pods/Target Support Files/Pods-Zovi/ExpoModulesProvider.swift",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Zovi/expo-configure-project.sh\"\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
13B07F871A680F5B00A75B9A /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
E4F7C222FFFCC78DE13D287F /* ExpoModulesProvider.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 01B1386BDA70C35F3BB3A395 /* Pods-Zovi.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Zovi/Zovi.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 75MMB3DXA8;
ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"FB_SONARKIT_ENABLED=1",
);
INFOPLIST_FILE = Zovi/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.altricade.messenger;
PRODUCT_NAME = Zovi;
SWIFT_OBJC_BRIDGING_HEADER = "Zovi/Zovi-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = DE9441A6824A495DF6F5687D /* Pods-Zovi.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Zovi/Zovi.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 75MMB3DXA8;
INFOPLIST_FILE = Zovi/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.altricade.messenger;
PRODUCT_NAME = Zovi;
SWIFT_OBJC_BRIDGING_HEADER = "Zovi/Zovi-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
83CBBA201A601CBA00E9B192 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_SYMBOLS_PRIVATE_EXTERN = NO;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
USE_HERMES = true;
};
name = Debug;
};
83CBBA211A601CBA00E9B192 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = YES;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = NO;
REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
USE_HERMES = true;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Zovi" */ = {
isa = XCConfigurationList;
buildConfigurations = (
13B07F941A680F5B00A75B9A /* Debug */,
13B07F951A680F5B00A75B9A /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Zovi" */ = {
isa = XCConfigurationList;
buildConfigurations = (
83CBBA201A601CBA00E9B192 /* Debug */,
83CBBA211A601CBA00E9B192 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;
}

View file

@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1130"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "Zovi.app"
BlueprintName = "Zovi"
ReferencedContainer = "container:Zovi.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "00E356ED1AD99517003FC87E"
BuildableName = "ZoviTests.xctest"
BlueprintName = "ZoviTests"
ReferencedContainer = "container:Zovi.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "Zovi.app"
BlueprintName = "Zovi"
ReferencedContainer = "container:Zovi.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "Zovi.app"
BlueprintName = "Zovi"
ReferencedContainer = "container:Zovi.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Zovi.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View file

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

View file

@ -0,0 +1,14 @@
{
"images": [
{
"filename": "App-Icon-1024x1024@1x.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}
],
"info": {
"version": 1,
"author": "expo"
}
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "expo"
}
}

View file

@ -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"
}
}

View file

@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Zovi</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>zovi</string>
<string>com.altricade.messenger</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>Zovi uses the camera for video messages.</string>
<key>NSFaceIDUsageDescription</key>
<string>Allow $(PRODUCT_NAME) to access your Face ID biometric data.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Zovi uses the microphone to record voice messages.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Zovi accesses your photos to share images and videos.</string>
<key>NSUserActivityTypes</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
</array>
<key>RCTNewArchEnabled</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UIRequiresFullScreen</key>
<false/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Automatic</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>

View file

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
<string>0A2A.1</string>
<string>3B52.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>E174.1</string>
<string>85F4.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
</array>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyTracking</key>
<false/>
</dict>
</plist>

View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="24093.7" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="EXPO-VIEWCONTROLLER-1">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="24053.1"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<scene sceneID="EXPO-SCENE-1">
<objects>
<viewController storyboardIdentifier="SplashScreenViewController" id="EXPO-VIEWCONTROLLER-1" sceneMemberID="viewController">
<view key="view" userInteractionEnabled="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="EXPO-ContainerView" userLabel="ContainerView">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews/>
<viewLayoutGuide key="safeArea" id="Rmq-lb-GrQ"/>
<constraints>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="centerY" secondItem="EXPO-ContainerView" secondAttribute="centerY" id="0VC-Wk-OaO"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="centerX" secondItem="EXPO-ContainerView" secondAttribute="centerX" id="zR4-NK-mVN"/>
</constraints>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="EXPO-PLACEHOLDER-1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="0.0" y="0.0"/>
</scene>
</scenes>
<resources>
<image name="SplashScreenLogo" width="100" height="90.333335876464844"/>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>EXUpdatesCheckOnLaunch</key>
<string>ALWAYS</string>
<key>EXUpdatesEnabled</key>
<false/>
<key>EXUpdatesLaunchWaitMs</key>
<integer>0</integer>
</dict>
</plist>

View file

@ -0,0 +1,3 @@
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
</dict>
</plist>

View file

@ -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;

View file

@ -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"
}
}

View file

@ -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,
};

View file

@ -0,0 +1,9 @@
export { apiConfig, apiBaseUrl, wsUrl } from './config';
export {
getAccessToken,
setAccessToken,
getRefreshToken,
setRefreshToken,
loadRefreshToken,
clearTokens,
} from './token-store';

View file

@ -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<string | null> => {
refreshToken = await SecureStore.getItemAsync(REFRESH_KEY);
return refreshToken;
};
export const setRefreshToken = async (token: string | null): Promise<void> => {
refreshToken = token;
if (token === null) {
await SecureStore.deleteItemAsync(REFRESH_KEY);
} else {
await SecureStore.setItemAsync(REFRESH_KEY, token);
}
};
export const clearTokens = async (): Promise<void> => {
accessToken = null;
await setRefreshToken(null);
};

View file

@ -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 (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<Pressable style={styles.backdrop} onPress={onClose}>
<Pressable
style={[
styles.sheet,
{ backgroundColor: colors.surfacePanel, paddingBottom: insets.bottom + spacing.sm },
]}
onPress={(event) => {
event.stopPropagation();
}}
>
{header}
{title !== undefined ? (
<Text style={[styles.title, { color: colors.textFaint }]}>{title}</Text>
) : null}
{actions.map((action) => (
<Pressable
key={action.key}
style={({ pressed }) => [styles.item, pressed && { backgroundColor: colors.surfaceHover }]}
onPress={() => {
action.onPress();
onClose();
}}
>
{action.icon !== undefined ? (
<Icon
name={action.icon}
size={20}
color={action.destructive === true ? colors.danger : colors.textMuted}
/>
) : null}
<Text
style={[
styles.label,
{ color: action.destructive === true ? colors.danger : colors.text },
]}
>
{action.label}
</Text>
</Pressable>
))}
</Pressable>
</Pressable>
</Modal>
);
};
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 },
});

View file

@ -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 (
<View style={{ width: size, height: size }}>
{uri !== undefined && uri !== null ? (
<Image
source={{ uri }}
style={{ width: size, height: size, borderRadius: size / 2 }}
contentFit="cover"
transition={120}
/>
) : (
<View
style={[
styles.placeholder,
{ width: size, height: size, borderRadius: size / 2, backgroundColor: colors.accent },
]}
>
{icon !== undefined ? (
<Icon name={icon} size={size * 0.5} color={colors.onAccent} />
) : (
<Text style={[styles.initial, { color: colors.onAccent, fontSize: size * 0.42 }]}>
{(name ?? '?').charAt(0).toUpperCase()}
</Text>
)}
</View>
)}
{online ? (
<View
style={[
styles.dot,
{
width: dot,
height: dot,
borderRadius: dot / 2,
backgroundColor: colors.online,
borderColor: colors.surfacePanel,
},
]}
/>
) : null}
</View>
);
};
const styles = StyleSheet.create({
placeholder: {
alignItems: 'center',
justifyContent: 'center',
},
initial: {
fontWeight: '700',
},
dot: {
position: 'absolute',
right: -1,
bottom: -1,
borderWidth: 2.5,
},
});

View file

@ -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<Props, State> {
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 (
<View style={styles.root}>
<Text style={styles.title}>Something went wrong</Text>
<Text style={styles.message}>{this.state.error.message}</Text>
<Pressable style={styles.button} onPress={this.reset}>
<Text style={styles.buttonText}>Try again</Text>
</Pressable>
</View>
);
}
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 },
});

View file

@ -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<IconName, ReactNode> = {
send: <Path d="M3.4 20.4 20.85 12.1a1 1 0 0 0 0-1.8L3.4 2A.7.7 0 0 0 2.4 2.7L4.5 11 2.4 21.3a.7.7 0 0 0 1 .1z" fill="currentColor" stroke="none" />,
mic: (
<>
<Path d="M9 5 v6 a3 3 0 0 0 6 0 v-6 a3 3 0 0 0 -6 0 z" />
<Path d="M5 10a7 7 0 0 0 14 0" />
<Line x1="12" y1="17" x2="12" y2="22" />
<Line x1="8" y1="22" x2="16" y2="22" />
</>
),
camera: (
<>
<Path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
<Circle cx="12" cy="13" r="4" />
</>
),
paperclip: (
<Path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
),
image: (
<>
<Path d="M5 3 h14 a2 2 0 0 1 2 2 v14 a2 2 0 0 1 -2 2 h-14 a2 2 0 0 1 -2 -2 v-14 a2 2 0 0 1 2 -2 z" />
<Circle cx="8.5" cy="8.5" r="1.5" />
<Path d="M21 15l-5-5L5 21" />
</>
),
play: <Path d="M7 4.5v15a1 1 0 0 0 1.53.85l12-7.5a1 1 0 0 0 0-1.7l-12-7.5A1 1 0 0 0 7 4.5z" fill="currentColor" stroke="none" />,
pause: (
<>
<Path d="M7.2 4.5 h1.6 a1.2 1.2 0 0 1 1.2 1.2 v12.6 a1.2 1.2 0 0 1 -1.2 1.2 h-1.6 a1.2 1.2 0 0 1 -1.2 -1.2 v-12.6 a1.2 1.2 0 0 1 1.2 -1.2 z" fill="currentColor" stroke="none" />
<Path d="M15.2 4.5 h1.6 a1.2 1.2 0 0 1 1.2 1.2 v12.6 a1.2 1.2 0 0 1 -1.2 1.2 h-1.6 a1.2 1.2 0 0 1 -1.2 -1.2 v-12.6 a1.2 1.2 0 0 1 1.2 -1.2 z" fill="currentColor" stroke="none" />
</>
),
stop: <Path d="M8 6 h8 a2 2 0 0 1 2 2 v8 a2 2 0 0 1 -2 2 h-8 a2 2 0 0 1 -2 -2 v-8 a2 2 0 0 1 2 -2 z" fill="currentColor" stroke="none" />,
check: <Polyline points="20 6 9 17 4 12" />,
doubleCheck: (
<>
<Path d="M2 12.5l4.5 4.5L16 7" />
<Path d="M11 16.5l1 1L22 7" />
</>
),
close: (
<>
<Line x1="18" y1="6" x2="6" y2="18" />
<Line x1="6" y1="6" x2="18" y2="18" />
</>
),
refresh: (
<>
<Polyline points="23 4 23 10 17 10" />
<Path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</>
),
chevronLeft: <Polyline points="15 18 9 12 15 6" />,
chevronRight: <Polyline points="9 18 15 12 9 6" />,
chevronDown: <Polyline points="6 9 12 15 18 9" />,
search: (
<>
<Circle cx="11" cy="11" r="8" />
<Line x1="21" y1="21" x2="16.65" y2="16.65" />
</>
),
plus: (
<>
<Line x1="12" y1="5" x2="12" y2="19" />
<Line x1="5" y1="12" x2="19" y2="12" />
</>
),
users: (
<>
<Path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<Circle cx="9" cy="7" r="4" />
<Path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
</>
),
megaphone: (
<>
<Path d="M3 11l17-7v16L3 13v-2z" />
<Path d="M7.5 13.5V19a1.5 1.5 0 0 0 3 0v-4" />
</>
),
settings: (
<>
<Circle cx="12" cy="12" r="3" />
<Path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.01a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h.01a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v.01a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</>
),
user: (
<>
<Path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<Circle cx="12" cy="7" r="4" />
</>
),
trash: (
<>
<Polyline points="3 6 5 6 21 6" />
<Path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</>
),
edit: (
<>
<Path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<Path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</>
),
reply: (
<>
<Polyline points="9 17 4 12 9 7" />
<Path d="M20 18v-2a4 4 0 0 0-4-4H4" />
</>
),
forward: (
<>
<Polyline points="15 17 20 12 15 7" />
<Path d="M4 18v-2a4 4 0 0 1 4-4h12" />
</>
),
pin: (
<>
<Path d="M12 17v5" />
<Path d="M9 3h6l-1 7 3 2v3H7v-3l3-2-1-7z" />
</>
),
pinOff: (
<>
<Path d="M12 17v5" />
<Path d="M9 3h6l-1 7 3 2v3H7v-3l3-2-1-7z" />
<Line x1="3" y1="3" x2="21" y2="21" />
</>
),
copy: (
<>
<Path d="M11 9 h9 a2 2 0 0 1 2 2 v9 a2 2 0 0 1 -2 2 h-9 a2 2 0 0 1 -2 -2 v-9 a2 2 0 0 1 2 -2 z" />
<Path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</>
),
download: (
<>
<Path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<Polyline points="7 10 12 15 17 10" />
<Line x1="12" y1="15" x2="12" y2="3" />
</>
),
file: (
<>
<Path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<Polyline points="14 2 14 8 20 8" />
</>
),
moon: <Path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />,
sun: (
<>
<Circle cx="12" cy="12" r="4" />
<Path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</>
),
monitor: (
<>
<Path d="M4 3 h16 a2 2 0 0 1 2 2 v10 a2 2 0 0 1 -2 2 h-16 a2 2 0 0 1 -2 -2 v-10 a2 2 0 0 1 2 -2 z" />
<Line x1="8" y1="21" x2="16" y2="21" />
<Line x1="12" y1="17" x2="12" y2="21" />
</>
),
logout: (
<>
<Path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<Polyline points="16 17 21 12 16 7" />
<Line x1="21" y1="12" x2="9" y2="12" />
</>
),
more: (
<>
<Circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none" />
<Circle cx="19" cy="12" r="1.6" fill="currentColor" stroke="none" />
<Circle cx="5" cy="12" r="1.6" fill="currentColor" stroke="none" />
</>
),
chats: (
<Path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
),
phone: (
<Path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.9.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z" />
),
};
export const Icon = ({ name, size = 22, color }: Props): ReactElement => (
<Svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
color={color}
>
{STROKE[name]}
</Svg>
);

View file

@ -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 (
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
hitSlop={8}
onPress={() => {
if (haptic) {
void Haptics.selectionAsync();
}
onPress();
}}
style={({ pressed }) => [
styles.button,
accent && { backgroundColor: colors.accent },
pressed && { opacity: 0.6 },
]}
>
<Icon name={name} size={size} color={color ?? (accent ? colors.onAccent : colors.textMuted)} />
</Pressable>
);
};
const styles = StyleSheet.create({
button: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
},
});

View file

@ -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 (
<View
style={[
styles.root,
{
backgroundColor: backgroundColor ?? colors.background,
paddingTop: edges?.top === false ? 0 : insets.top,
paddingBottom: edges?.bottom === true ? insets.bottom : 0,
},
]}
>
{children}
</View>
);
};
const styles = StyleSheet.create({
root: { flex: 1 },
});

View file

@ -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 (
<View
style={[
styles.bar,
{ borderBottomColor: colors.border },
showBack ? null : styles.barNoBack,
]}
>
{showBack ? (
<IconButton
name="chevronLeft"
onPress={onBack ?? (() => { router.back(); })}
accessibilityLabel="Back"
/>
) : null}
<Text style={[styles.title, { color: colors.text }]} numberOfLines={1}>
{title}
</Text>
<View style={styles.right}>{right}</View>
</View>
);
};
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 },
});

View file

@ -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 (
<Pressable
onPress={() => {
onPress(user);
}}
style={({ pressed }) => [styles.row, pressed && { backgroundColor: colors.surfaceHover }]}
>
<Avatar uri={user.avatarUrl} name={user.displayName} size={46} />
<View style={styles.body}>
<Text style={[styles.name, { color: colors.text }]} numberOfLines={1}>
{user.displayName}
</Text>
<Text style={[styles.sub, { color: colors.textFaint }]} numberOfLines={1}>
{subtitle ?? `@${user.username}`}
</Text>
</View>
{selected === true ? (
<View style={[styles.check, { backgroundColor: colors.accent }]}>
<Icon name="check" size={14} color={colors.onAccent} />
</View>
) : null}
</Pressable>
);
};
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' },
});

View file

@ -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';

View file

@ -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<Mode>('login');
const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(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 (
<Screen>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.flex}
>
<View style={styles.center}>
<View style={[styles.mark, { backgroundColor: colors.accent }]}>
<Text style={[styles.markText, { color: colors.onAccent }]}>Z</Text>
</View>
<Text style={[styles.title, { color: colors.text }]}>Zovi</Text>
<Text style={[styles.tagline, { color: colors.textMuted }]}>
{mode === 'login' ? 'Welcome back.' : 'Create your account.'}
</Text>
<TextInput
style={inputStyle}
placeholder="Username"
placeholderTextColor={colors.textFaint}
autoCapitalize="none"
autoCorrect={false}
value={username}
onChangeText={setUsername}
/>
{mode === 'register' ? (
<TextInput
style={inputStyle}
placeholder="Display name"
placeholderTextColor={colors.textFaint}
value={displayName}
onChangeText={setDisplayName}
/>
) : null}
<TextInput
style={inputStyle}
placeholder="Password"
placeholderTextColor={colors.textFaint}
secureTextEntry
value={password}
onChangeText={setPassword}
/>
{error !== null ? <Text style={[styles.error, { color: colors.danger }]}>{error}</Text> : null}
<Pressable
style={[styles.primary, { backgroundColor: colors.accent }, busy && { opacity: 0.6 }]}
disabled={busy}
onPress={submit}
>
{busy ? (
<ActivityIndicator color={colors.onAccent} />
) : (
<Text style={[styles.primaryText, { color: colors.onAccent }]}>
{mode === 'login' ? 'Log in' : 'Create account'}
</Text>
)}
</Pressable>
<Pressable
onPress={() => {
setMode(mode === 'login' ? 'register' : 'login');
setError(null);
}}
>
<Text style={[styles.switch, { color: colors.accent }]}>
{mode === 'login' ? 'Need an account? Sign up' : 'Have an account? Log in'}
</Text>
</Pressable>
</View>
</KeyboardAvoidingView>
</Screen>
);
};
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' },
});

View file

@ -0,0 +1 @@
export { SignInScreen } from './SignInScreen';

View file

@ -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<PublicUser[]>([]);
const [results, setResults] = useState<PublicUser[] | null>(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 (
<Screen>
<ScreenHeader title="Contacts" showBack={false} />
<View style={styles.searchWrap}>
<TextInput
value={query}
onChangeText={setQuery}
placeholder="Search people by @username"
placeholderTextColor={colors.textFaint}
autoCapitalize="none"
style={[styles.search, { backgroundColor: colors.surface, color: colors.text }]}
/>
</View>
{busy ? <ActivityIndicator color={colors.accent} style={styles.busy} /> : null}
<LegendList
data={data}
keyExtractor={(item) => item.id}
estimatedItemSize={62}
renderItem={({ item }) => <UserRow user={item} onPress={open} />}
ListEmptyComponent={
<Text style={[styles.empty, { color: colors.textFaint }]}>
{results === null ? 'No contacts yet' : 'No people found'}
</Text>
}
/>
</Screen>
);
};
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 },
});

View file

@ -0,0 +1 @@
export { ContactsScreen } from './ContactsScreen';

View file

@ -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<Conversation | null>(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 (
<Screen>
<View style={[styles.header, { borderBottomColor: colors.border }]}>
<Text style={[styles.brand, { color: colors.text }]}>Zovi</Text>
<View style={styles.headerActions}>
<IconButton name="search" onPress={() => { router.push('/contacts'); }} accessibilityLabel="Search" />
<IconButton name="plus" onPress={() => { setCompose(true); }} accessibilityLabel="New chat" />
<Pressable onPress={() => { router.push('/settings'); }} hitSlop={6}>
<Avatar uri={user?.avatarUrl} name={user?.displayName} size={34} />
</Pressable>
</View>
</View>
{loading ? (
<View style={styles.center}>
<ActivityIndicator color={colors.accent} />
</View>
) : conversations.length === 0 ? (
<View style={styles.center}>
<Icon name="chats" size={40} color={colors.textFaint} />
<Text style={[styles.empty, { color: colors.textMuted }]}>No conversations yet</Text>
</View>
) : (
<LegendList
data={conversations}
keyExtractor={(item) => item.id}
estimatedItemSize={68}
renderItem={({ item }) => (
<ConversationRow
conversation={item}
meId={meId}
online={item.peer !== null && onlineMap[item.peer.id] === true}
onPress={(c) => { router.push(`/chat/${c.id}`); }}
onLongPress={setRowMenu}
/>
)}
/>
)}
<ActionSheet visible={compose} actions={composeActions} onClose={() => { setCompose(false); }} />
<ActionSheet
visible={rowMenu !== null}
title={rowMenu !== null ? title(rowMenu) : undefined}
actions={rowMenu !== null ? rowActions(rowMenu) : []}
onClose={() => { setRowMenu(null); }}
/>
</Screen>
);
};
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 },
});

View file

@ -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 (
<Pressable
onPress={() => {
onPress(conversation);
}}
onLongPress={() => {
onLongPress(conversation);
}}
style={({ pressed }) => [styles.row, pressed && { backgroundColor: colors.surfaceHover }]}
>
<Avatar
uri={conversation.avatarUrl}
name={title(conversation)}
{...(icon !== undefined ? { icon } : {})}
online={online}
size={52}
/>
<View style={styles.main}>
<View style={styles.topRow}>
<Text style={[styles.name, { color: colors.text }]} numberOfLines={1}>
{title(conversation)}
</Text>
<Text style={[styles.time, { color: colors.textFaint }]}>
{formatTime(conversation.lastMessageAt)}
</Text>
</View>
<View style={styles.bottomRow}>
<Text style={[styles.preview, { color: colors.textMuted }]} numberOfLines={1}>
{subtitle(conversation, meId)}
</Text>
{conversation.unreadCount > 0 ? (
<View style={[styles.badge, { backgroundColor: colors.accent }]}>
<Text style={[styles.badgeText, { color: colors.onAccent }]}>
{conversation.unreadCount}
</Text>
</View>
) : null}
</View>
</View>
</Pressable>
);
};
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' },
});

View file

@ -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<PublicUser[]>([]);
const [selected, setSelected] = useState<PublicUser[]>([]);
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 = (
<Pressable onPress={create} disabled={title.trim().length === 0} hitSlop={8}>
<Text
style={[
styles.create,
{ color: title.trim().length === 0 ? colors.textFaint : colors.accent },
]}
>
Create
</Text>
</Pressable>
);
return (
<Screen>
<ScreenHeader title={mode === 'group' ? 'New Group' : 'New Channel'} right={createButton} />
<ScrollView keyboardShouldPersistTaps="handled">
<TextInput
value={title}
onChangeText={setTitle}
placeholder={mode === 'group' ? 'Group name' : 'Channel name'}
placeholderTextColor={colors.textFaint}
style={[styles.field, { backgroundColor: colors.surface, color: colors.text }]}
/>
{mode === 'channel' ? (
<TextInput
value={description}
onChangeText={setDescription}
placeholder="Description (optional)"
placeholderTextColor={colors.textFaint}
multiline
style={[styles.field, styles.multiline, { backgroundColor: colors.surface, color: colors.text }]}
/>
) : null}
{selected.length > 0 ? (
<View style={styles.chips}>
{selected.map((user) => (
<Pressable
key={user.id}
onPress={() => {
toggle(user);
}}
style={[styles.chip, { backgroundColor: colors.accentSoft }]}
>
<Text style={[styles.chipText, { color: colors.accent }]}>{user.displayName}</Text>
<Icon name="close" size={14} color={colors.accent} />
</Pressable>
))}
</View>
) : null}
<TextInput
value={query}
onChangeText={setQuery}
placeholder="Add people by @username"
placeholderTextColor={colors.textFaint}
autoCapitalize="none"
style={[styles.field, { backgroundColor: colors.surface, color: colors.text }]}
/>
{results.map((user) => (
<UserRow
key={user.id}
user={user}
selected={selected.some((u) => u.id === user.id)}
onPress={toggle}
/>
))}
</ScrollView>
</Screen>
);
};
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' },
});

View file

@ -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';

View file

@ -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<string, boolean>;
startDirect: (username: string) => Promise<Conversation>;
createGroupChat: (title: string, members: string[]) => Promise<Conversation>;
createChannelChat: (title: string, description: string | null, members: string[]) => Promise<Conversation>;
clearChat: (conversationId: string) => Promise<void>;
deleteChat: (conversationId: string) => Promise<void>;
}
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<Conversation[]>([]);
const [loading, setLoading] = useState(true);
const [onlineMap, setOnlineMap] = useState<Record<string, boolean>>({});
const presentByChannel = useRef<Map<string, Set<string>>>(new Map());
const recomputeOnline = useCallback((): void => {
const online: Record<string, boolean> = {};
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<string>();
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<Conversation> => {
const conversation = await createDirect(apiConfig, { username });
setConversations((prev) => upsert(prev, conversation));
return conversation;
}, []);
const createGroupChat = useCallback(
async (title: string, members: string[]): Promise<Conversation> => {
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<Conversation> => {
const conversation = await createChannel(apiConfig, {
title,
...(description !== null ? { description } : {}),
members,
});
setConversations((prev) => upsert(prev, conversation));
return conversation;
},
[],
);
const clearChat = useCallback(async (conversationId: string): Promise<void> => {
await clearConversation(apiConfig, conversationId);
setConversations((prev) =>
prev.map((c) => (c.id === conversationId ? { ...c, unreadCount: 0 } : c)),
);
}, []);
const deleteChat = useCallback(async (conversationId: string): Promise<void> => {
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)));
};

View file

@ -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<number> => {
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<MediaAsset> => {
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<MediaAsset | null> => {
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<MediaAsset | null> => {
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<MediaAsset | null> => {
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',
};
};

View file

@ -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);
};

View file

@ -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';

View file

@ -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<string | null>(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'}`;
};

View file

@ -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<void>;
sendMedia: (asset: MediaAsset, caption: string) => Promise<void>;
retryUpload: (uploadId: string) => void;
cancelUpload: (uploadId: string) => void;
jumpTo: (messageId: string) => Promise<boolean>;
loadOlder: () => Promise<void>;
loadNewer: () => Promise<void>;
reloadTail: () => Promise<void>;
edit: (messageId: string, content: string) => Promise<void>;
remove: (messageId: string) => Promise<void>;
hide: (messageId: string) => Promise<void>;
toggleReaction: (message: Message, emoji: string) => Promise<void>;
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<Message[]>([]);
const [loading, setLoading] = useState(true);
const [typingUserIds, setTypingUserIds] = useState<string[]>([]);
const [peerReadSeq, setPeerReadSeq] = useState(0);
const [pinned, setPinned] = useState<Message[]>([]);
const [detached, setDetached] = useState(false);
const [hasMoreUp, setHasMoreUp] = useState(true);
const messagesRef = useRef<Message[]>([]);
messagesRef.current = messages;
const latestSeqRef = useRef(0);
const detachedRef = useRef(false);
detachedRef.current = detached;
const pagingRef = useRef(false);
const typingTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const 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<void> => {
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<boolean> => {
if (messagesRef.current.some((m) => m.id === messageId)) {
return true;
}
try {
const context = await getMessageContext(apiConfig, conversationId, messageId, 60);
if (!context.some((m) => m.id === messageId)) {
return false;
}
const maxSeq = 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<void> => {
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<void> => {
if (pagingRef.current || !detachedRef.current) {
return;
}
const real = messagesRef.current.filter((m) => m.seq !== OPTIMISTIC_SEQ);
const last = real[real.length - 1];
if (last === undefined) {
return;
}
pagingRef.current = true;
try {
const batch = await getHistory(apiConfig, conversationId, { after: last.seq, limit: 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<void> => {
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<void> => {
const updated = await apiEdit(apiConfig, conversationId, messageId, { content });
setMessages((prev) => mergeMessages(prev, [updated]));
},
[conversationId],
);
const remove = useCallback(
async (messageId: string): Promise<void> => {
await apiDelete(apiConfig, conversationId, messageId);
},
[conversationId],
);
const hide = useCallback(
async (messageId: string): Promise<void> => {
setMessages((prev) => prev.filter((m) => m.id !== messageId));
await apiHide(apiConfig, conversationId, messageId);
},
[conversationId],
);
const toggleReaction = useCallback(
async (message: Message, emoji: string): Promise<void> => {
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,
};
};

View file

@ -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;
};

View file

@ -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 (
<View style={[styles.bar, { borderBottomColor: colors.border, backgroundColor: colors.surfacePanel }]}>
<IconButton name="chevronLeft" onPress={onBack} accessibilityLabel="Back" />
<Pressable style={styles.center} onPress={onOpenInfo}>
<Avatar
uri={conversation.avatarUrl}
name={titleOf(conversation)}
{...(icon !== undefined ? { icon } : {})}
online={conversation.type === 'direct' && online}
size={38}
/>
<View style={styles.text}>
<Text style={[styles.title, { color: colors.text }]} numberOfLines={1}>
{titleOf(conversation)}
</Text>
<Text
style={[styles.subtitle, { color: typing ? colors.accent : colors.textFaint }]}
numberOfLines={1}
>
{subtitleOf(conversation, online, typing)}
</Text>
</View>
</Pressable>
</View>
);
};
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 },
});

View file

@ -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<string, string>();
const NEAR_BOTTOM_PX = 80;
export const ChatScreen = ({ conversationId }: Props): ReactElement => {
const { colors } = useTheme();
const user = useSession((s) => s.user);
const [conversation, setConversation] = useState<Conversation | null>(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 (
<Screen>
<View style={styles.center}>
<ActivityIndicator color={colors.accent} />
</View>
</Screen>
);
}
return <ChatBody conversation={conversation} me={toPublicUser(user)} />;
};
const ChatBody = ({
conversation,
me,
}: {
conversation: Conversation;
me: PublicUser;
}): ReactElement => {
const { colors } = useTheme();
const router = useRouter();
const model = useConversationMessages(conversation, me);
const listRef = useRef<LegendListRef>(null);
const [replyingTo, setReplyingTo] = useState<Message | null>(null);
const [editing, setEditing] = useState<Message | null>(null);
const [actionTarget, setActionTarget] = useState<Message | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Message | null>(null);
const [attachOpen, setAttachOpen] = useState(false);
const [recording, setRecording] = useState(false);
const [viewer, setViewer] = useState<ViewerSource | null>(null);
const [highlightId, setHighlightId] = useState<string | null>(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<string | null>(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<void> => {
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<NativeScrollEvent>): 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<MediaAsset | null>): Promise<void> => {
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 (
<Screen>
<ChatHeader
conversation={conversation}
online={false}
typing={model.typingUserIds.length > 0}
onBack={() => {
router.back();
}}
onOpenInfo={() => {
router.push(
isGroup ? `/group/${conversation.id}` : `/profile/${conversation.peer?.username ?? ''}`,
);
}}
/>
<PinnedBar
pinned={model.pinned}
onJump={(id) => {
void jumpToMessage(id);
}}
/>
<KeyboardAvoidingView style={styles.flex} behavior="padding">
{model.loading ? (
<View style={styles.center}>
<ActivityIndicator color={colors.accent} />
</View>
) : (
<LegendList
ref={listRef}
data={rows}
keyExtractor={(item) => 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' ? (
<View style={styles.divider}>
<Text style={[styles.dividerText, { backgroundColor: colors.surface, color: colors.textMuted }]}>
{item.label}
</Text>
</View>
) : (
<SwipeToReply
onReply={() => {
setEditing(null);
setReplyingTo(item.message);
}}
>
<MessageBubble
message={item.message}
meId={me.id}
isGroup={isGroup}
peerReadSeq={model.peerReadSeq}
highlighted={highlightId === item.message.id}
{...bubbleCallbacks}
/>
</SwipeToReply>
)
}
/>
)}
{model.typingUserIds.length > 0 ? <TypingIndicator /> : null}
{recording ? (
<VoiceRecorder
onSend={(asset) => {
sendMediaAsset(asset);
setRecording(false);
}}
onCancel={() => {
setRecording(false);
}}
/>
) : (
<Composer
replyingTo={replyingTo}
editing={editing}
onCancelReply={() => {
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}
/>
)}
</KeyboardAvoidingView>
<MessageActionSheet
message={actionTarget}
meId={me.id}
isPinned={actionTarget !== null && pinnedIds.has(actionTarget.id)}
onClose={() => {
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);
}}
/>
<ActionSheet
visible={deleteTarget !== null}
title="Delete message"
actions={deleteActions}
onClose={() => {
setDeleteTarget(null);
}}
/>
<ActionSheet
visible={attachOpen}
actions={attachActions}
onClose={() => {
setAttachOpen(false);
}}
/>
<MediaViewer
source={viewer}
onClose={() => {
setViewer(null);
}}
/>
</Screen>
);
};
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',
},
});

View file

@ -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 (
<View style={[styles.wrap, { backgroundColor: colors.surfacePanel, borderColor: colors.border }]}>
{banner !== null ? (
<View style={[styles.banner, { borderColor: colors.border }]}>
<View style={[styles.bannerBar, { backgroundColor: colors.accent }]} />
<View style={styles.bannerBody}>
<Text style={[styles.bannerName, { color: colors.accent }]} numberOfLines={1}>
{bannerLabel}
</Text>
<Text style={[styles.bannerText, { color: colors.textMuted }]} numberOfLines={1}>
{banner.content.length > 0 ? banner.content : 'Media message'}
</Text>
</View>
<Pressable onPress={editing !== null ? onCancelEdit : onCancelReply} hitSlop={8}>
<Icon name="close" size={18} color={colors.textFaint} />
</Pressable>
</View>
) : null}
<View style={styles.row}>
{editing === null ? (
<IconButton name="paperclip" onPress={onAttach} accessibilityLabel="Attach" />
) : null}
<TextInput
value={text}
onChangeText={(value) => {
setText(value);
onTyping();
}}
placeholder="Message"
placeholderTextColor={colors.textFaint}
multiline
style={[styles.input, { color: colors.text, backgroundColor: colors.surface }]}
/>
{canSend ? (
<Pressable onPress={submit} style={[styles.send, { backgroundColor: colors.accent }]}>
<Icon name={editing !== null ? 'check' : 'send'} size={20} color={colors.onAccent} />
</Pressable>
) : (
<Pressable onPress={onStartVoice} style={[styles.send, { backgroundColor: colors.accent }]}>
<Icon name="mic" size={20} color={colors.onAccent} />
</Pressable>
)}
</View>
</View>
);
};
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' },
});

View file

@ -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 (
<Pressable
disabled={url === null}
onPress={() => {
if (url !== null) {
void Linking.openURL(url);
}
}}
style={styles.row}
>
<View style={[styles.icon, { backgroundColor: outgoing ? colors.onAccentMuted : colors.accentSoft }]}>
<Icon name="file" size={20} color={outgoing ? colors.onAccent : colors.accent} />
</View>
<View style={styles.meta}>
<Text style={[styles.name, { color: nameTone }]} numberOfLines={1}>
{media.name}
</Text>
<Text style={[styles.size, { color: subTone }]}>{formatBytes(media.size)}</Text>
</View>
</Pressable>
);
};
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 },
});

View file

@ -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<Conversation[]>([]);
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<void> => {
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 (
<Screen>
<ScreenHeader title="Forward to" />
<LegendList
data={conversations}
keyExtractor={(item) => item.id}
estimatedItemSize={62}
renderItem={({ item }) => {
const icon =
item.type === 'channel' ? 'megaphone' : item.type === 'group' ? 'users' : undefined;
return (
<Pressable
onPress={() => {
forwardTo(item);
}}
style={({ pressed }) => [styles.row, pressed && { backgroundColor: colors.surfaceHover }]}
>
<Avatar
uri={item.avatarUrl}
name={titleOf(item)}
{...(icon !== undefined ? { icon } : {})}
size={46}
/>
<Text style={[styles.name, { color: colors.text }]} numberOfLines={1}>
{titleOf(item)}
</Text>
</Pressable>
);
}}
/>
</Screen>
);
};
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' },
});

View file

@ -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 (
<Pressable
disabled={username === null || onPressUser === undefined}
onPress={() => {
if (username !== null) {
onPressUser?.(username);
}
}}
>
<Text style={[styles.label, { color: label }]}>Forwarded from</Text>
<Text style={[styles.name, { color: name }]} numberOfLines={1}>
{origin?.name ?? 'Hidden account'}
</Text>
</Pressable>
);
};
const styles = StyleSheet.create({
label: { fontSize: fontSize.xs, marginBottom: 1 },
name: { fontSize: fontSize.sm, fontWeight: '700', marginBottom: spacing.xs },
});

View file

@ -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 (
<MediaImage
conversationId={conversationId}
messageId={messageId}
media={media}
{...(localUri !== undefined ? { localUri } : {})}
onOpen={onOpenViewer}
/>
);
case 'video_note':
return <VideoNote conversationId={conversationId} messageId={messageId} media={media} />;
case 'voice':
return (
<VoicePlayer
conversationId={conversationId}
messageId={messageId}
media={media}
outgoing={outgoing}
/>
);
default:
return (
<FileBubble
conversationId={conversationId}
messageId={messageId}
media={media}
outgoing={outgoing}
/>
);
}
};

View file

@ -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<string, string>();
const useVideoThumbnail = (messageId: string, source: string | null, isVideo: boolean): string | null => {
const [thumb, setThumb] = useState<string | null>(() => {
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 (
<Pressable
onPress={() => {
if (uri !== null) {
onOpen(uri, isVideo);
}
}}
style={[styles.wrap, box, { backgroundColor: colors.surface }]}
>
{displayUri !== null ? (
<Image source={{ uri: displayUri }} style={box} contentFit="cover" transition={150} />
) : null}
{isVideo ? (
<View style={styles.overlay}>
<View style={styles.playBadge}>
<Icon name="play" size={22} color="#fff" />
</View>
</View>
) : null}
</Pressable>
);
};
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',
},
});

View file

@ -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 (
<GestureDetector gesture={composed}>
<Animated.View style={[styles.fill, animatedStyle]}>
<Image source={{ uri: url }} style={styles.fill} contentFit="contain" />
</Animated.View>
</GestureDetector>
);
};
const ViewerVideo = ({ url }: { url: string }): ReactElement => {
const player = useVideoPlayer({ uri: url }, (instance) => {
instance.play();
});
return <VideoView player={player} style={styles.fill} contentFit="contain" />;
};
// Full-screen image (pinch-zoom) / video overlay opened from a media bubble.
export const MediaViewer = ({ source, onClose }: Props): ReactElement => {
const insets = useSafeAreaInsets();
return (
<Modal visible={source !== null} transparent animationType="fade" onRequestClose={onClose}>
<View style={styles.backdrop}>
{source !== null ? (
source.isVideo ? (
<ViewerVideo url={source.url} />
) : (
<ZoomableImage url={source.url} />
)
) : null}
<Pressable style={[styles.close, { top: insets.top + 8 }]} onPress={onClose} hitSlop={8}>
<Icon name="close" size={26} color="#fff" />
</Pressable>
</View>
</Modal>
);
};
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',
},
});

View file

@ -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 : (
<View style={[styles.reactions, { backgroundColor: colors.surface }]}>
{QUICK_REACTIONS.map((emoji) => (
<Pressable
key={emoji}
onPress={() => {
onReact(emoji);
}}
style={styles.reaction}
>
<Text style={styles.emoji}>{emoji}</Text>
</Pressable>
))}
</View>
);
return (
<ActionSheet
visible={message !== null}
actions={actions}
onClose={onClose}
{...(header !== undefined ? { header } : {})}
/>
);
};
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 },
});

View file

@ -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 (
<View style={[styles.line, outgoing ? styles.lineOut : styles.lineIn]}>
<Pressable
onLongPress={() => {
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 ? (
<ForwardedHeader origin={message.forwardedFrom} outgoing={outgoing} onPressUser={onPressUser} />
) : null}
{showSender ? (
<Text style={[styles.sender, { color: colors.accent }]} numberOfLines={1}>
{message.sender.displayName}
</Text>
) : null}
{message.replyTo !== null ? (
<ReplyQuote
reply={message.replyTo}
outgoing={outgoing}
onPress={() => {
if (message.replyTo !== null) {
onReplyPress(message.replyTo.id);
}
}}
/>
) : null}
{message.media !== null && !deleted ? (
<View style={styles.media}>
<MediaContent
conversationId={message.conversationId}
messageId={message.id}
media={message.media}
outgoing={outgoing}
onOpenViewer={onOpenViewer}
/>
</View>
) : null}
{deleted ? (
<Text style={[styles.deleted, { color: outgoing ? colors.onAccentMuted : colors.textFaint }]}>
Message deleted
</Text>
) : hasText ? (
<Text style={[styles.text, { color: textColor }]}>{message.content}</Text>
) : null}
<MessageMeta
createdAt={message.createdAt}
edited={message.editedAt !== null}
outgoing={outgoing}
status={statusOf(message, outgoing, peerReadSeq)}
/>
</Pressable>
{!deleted ? (
<Reactions
reactions={message.reactions}
outgoing={outgoing}
onToggle={(emoji) => {
onToggleReaction(message, emoji);
}}
/>
) : null}
</View>
);
};
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 },
});

Some files were not shown because too many files have changed in this diff Show more