f
This commit is contained in:
parent
f5b620111b
commit
f653b083aa
139 changed files with 16867 additions and 156 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
setRefreshCookie(reply, issued.refreshToken);
|
||||
return reply.code(201).send(publicResult(issued));
|
||||
const tokenMode = isTokenMode(request);
|
||||
if (!tokenMode) {
|
||||
setRefreshCookie(reply, issued.refreshToken);
|
||||
}
|
||||
return reply.code(201).send(authResult(issued, tokenMode));
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -76,37 +101,54 @@ export const authRoutes = (app: FastifyInstance): Promise<void> => {
|
|||
},
|
||||
async (request, reply) => {
|
||||
const issued = await app.authService.login(request.body, context(request));
|
||||
setRefreshCookie(reply, issued.refreshToken);
|
||||
return reply.send(publicResult(issued));
|
||||
const tokenMode = isTokenMode(request);
|
||||
if (!tokenMode) {
|
||||
setRefreshCookie(reply, issued.refreshToken);
|
||||
}
|
||||
return reply.send(authResult(issued, tokenMode));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
app.post<{ Body?: { refreshToken?: string } }>(
|
||||
'/refresh',
|
||||
{
|
||||
schema: {
|
||||
tags: ['auth'],
|
||||
summary: 'Rotate tokens using the refresh cookie (reuse detection)',
|
||||
summary: 'Rotate tokens using the refresh cookie or body token (reuse detection)',
|
||||
body: refreshBodySchema,
|
||||
response: { 200: authResultSchema, 401: errorSchema },
|
||||
},
|
||||
config: authRateLimit,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const token = request.cookies[REFRESH_COOKIE];
|
||||
const tokenMode = isTokenMode(request);
|
||||
const token = tokenMode
|
||||
? refreshTokenFromBody(request.body)
|
||||
: request.cookies[REFRESH_COOKIE];
|
||||
if (token === undefined) {
|
||||
return reply.code(401).send({ error: 'invalid_token', message: 'Missing refresh token' });
|
||||
}
|
||||
const issued = await app.authService.refresh(token, context(request));
|
||||
setRefreshCookie(reply, issued.refreshToken);
|
||||
return reply.send(publicResult(issued));
|
||||
if (!tokenMode) {
|
||||
setRefreshCookie(reply, issued.refreshToken);
|
||||
}
|
||||
return reply.send(authResult(issued, tokenMode));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
app.post<{ Body?: { refreshToken?: string } }>(
|
||||
'/logout',
|
||||
{ schema: { tags: ['auth'], summary: 'Revoke the current refresh token (this device)' } },
|
||||
{
|
||||
schema: {
|
||||
tags: ['auth'],
|
||||
summary: 'Revoke the current refresh token (this device)',
|
||||
body: refreshBodySchema,
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const token = request.cookies[REFRESH_COOKIE];
|
||||
const token = isTokenMode(request)
|
||||
? refreshTokenFromBody(request.body)
|
||||
: request.cookies[REFRESH_COOKIE];
|
||||
if (token !== undefined) {
|
||||
await app.authService.logout(token);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import { Client } from 'minio';
|
|||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
minio: Client;
|
||||
// Client configured with the browser-facing host — used ONLY for presigning
|
||||
// upload/download URLs so the signature matches the host the browser hits.
|
||||
minioPublic: Client;
|
||||
// Returns a client configured for the given public origin (e.g. the host a
|
||||
// browser or phone reached the gateway on) — used ONLY for presigning, so
|
||||
// the S3 signature matches the host the device will actually hit. Falls
|
||||
// back to the configured MINIO_PUBLIC_URL when origin is null.
|
||||
minioPresign: (origin: string | null) => Client;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -18,20 +20,30 @@ export const minioPlugin = fp(
|
|||
const client = new Client({ endPoint: endpoint, port, useSSL, accessKey, secretKey, region });
|
||||
app.decorate('minio', client);
|
||||
|
||||
const parsed = new URL(publicUrl);
|
||||
const publicSecure = parsed.protocol === 'https:';
|
||||
const publicPort = parsed.port === '' ? (publicSecure ? 443 : 80) : Number.parseInt(parsed.port, 10);
|
||||
// Explicit region so presigning is purely computational — no getBucketRegion
|
||||
// network call to the browser-facing host (unreachable from inside the container).
|
||||
const publicClient = new Client({
|
||||
endPoint: parsed.hostname,
|
||||
port: publicPort,
|
||||
useSSL: publicSecure,
|
||||
accessKey,
|
||||
secretKey,
|
||||
region,
|
||||
});
|
||||
app.decorate('minioPublic', publicClient);
|
||||
// One presigning client per public origin, cached — presigning is purely
|
||||
// computational (explicit region → no getBucketRegion network call to a
|
||||
// host that's unreachable from inside the container).
|
||||
const presignClients = new Map<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: parsedPort,
|
||||
useSSL: secure,
|
||||
accessKey,
|
||||
secretKey,
|
||||
region,
|
||||
});
|
||||
presignClients.set(base, created);
|
||||
return created;
|
||||
};
|
||||
app.decorate('minioPresign', (origin: string | null) => clientFor(origin ?? publicUrl));
|
||||
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
|
|
|||
15
packages/backend/src/shared/request-origin.ts
Normal file
15
packages/backend/src/shared/request-origin.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
// The public origin the client used to reach the gateway (nginx forwards the
|
||||
// original Host verbatim and stamps X-Forwarded-Proto). Presigning media URLs
|
||||
// for THIS origin is what lets one backend serve browsers on localhost and
|
||||
// phones on a LAN IP simultaneously. Null when the request didn't come through
|
||||
// the proxy (direct dev access) — callers fall back to the configured URL.
|
||||
export const requestOrigin = (request: FastifyRequest): string | null => {
|
||||
const proto = request.headers['x-forwarded-proto'];
|
||||
const host = request.headers.host;
|
||||
if (typeof proto === 'string' && proto.length > 0 && typeof host === 'string' && host.length > 0) {
|
||||
return `${proto.split(',')[0] ?? proto}://${host}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
|
@ -22,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> => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
6
packages/mobile/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
|
||||
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||
# The following patterns were generated by expo-cli
|
||||
|
||||
expo-env.d.ts
|
||||
# @end expo-cli
|
||||
67
packages/mobile/README.md
Normal file
67
packages/mobile/README.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Zovi Mobile (Expo)
|
||||
|
||||
React Native + Expo (SDK 54) client for Altricade, consuming the shared
|
||||
`@altricade/core` package as source. FSD layout: `app/` holds thin expo-router
|
||||
route files only; all logic and UI live under `src/`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- The backend + infra stack running (Postgres, Redis, MinIO, Centrifugo) — see
|
||||
`infra/docker-compose.yml`. Start the Fastify backend so the API is on
|
||||
`http://localhost:8080`.
|
||||
- On a physical device the API/WS host must be reachable from the phone, so
|
||||
point the client at your machine's LAN IP (see env below), not `localhost`.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
pnpm install # from the repo root
|
||||
|
||||
# From packages/mobile:
|
||||
pnpm start # Metro + dev menu
|
||||
pnpm ios # build & run iOS (needs a dev build)
|
||||
pnpm android # build & run Android (needs a dev build)
|
||||
```
|
||||
|
||||
This app uses native modules (reanimated, gesture-handler, camera, audio,
|
||||
video, secure-store, notifications), so it requires a **development build**
|
||||
(`expo-dev-client`) — it will not run in Expo Go. Build one with EAS or
|
||||
`expo run:ios` / `expo run:android`.
|
||||
|
||||
### Environment
|
||||
|
||||
Public config is read from `app.config.ts` `extra`, driven by `EXPO_PUBLIC_*`:
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| ------------------------- | -------------------------------------------------- | -------------------------------- |
|
||||
| `EXPO_PUBLIC_API_URL` | `http://localhost:8080/api` | REST base URL |
|
||||
| `EXPO_PUBLIC_WS_URL` | `ws://localhost:8080/connection/websocket` | Centrifugo websocket |
|
||||
| `EXPO_PUBLIC_PUSH_ENABLED`| unset (off) | Enable native push registration |
|
||||
|
||||
Example for a device on your LAN:
|
||||
|
||||
```bash
|
||||
EXPO_PUBLIC_API_URL=http://192.168.1.20:8080/api \
|
||||
EXPO_PUBLIC_WS_URL=ws://192.168.1.20:8080/connection/websocket \
|
||||
pnpm start
|
||||
```
|
||||
|
||||
## Quality gates
|
||||
|
||||
```bash
|
||||
pnpm typecheck # tsc --noEmit (strict, no any/assertions/!)
|
||||
pnpm lint # eslint
|
||||
```
|
||||
|
||||
## Auth transport
|
||||
|
||||
Native uses token-mode auth: the refresh token is stored in `expo-secure-store`
|
||||
(Keychain/Keystore) and sent in the request body with an `X-Auth-Mode: token`
|
||||
header, instead of the web's httpOnly cookie. The access token stays in memory.
|
||||
|
||||
## Push notifications
|
||||
|
||||
Native push (FCM/APNs) needs a build with push credentials and cannot run in
|
||||
Expo Go, so registration is gated behind `EXPO_PUBLIC_PUSH_ENABLED`. Tap-to-open
|
||||
deep-linking (`src/services/usePush.ts`) is always wired, so once credentials
|
||||
are configured, flip the flag and notifications route into the right chat.
|
||||
46
packages/mobile/app.config.ts
Normal file
46
packages/mobile/app.config.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { ExpoConfig } from 'expo/config';
|
||||
|
||||
// Public runtime config only — real secrets stay server-side. The API/WS base
|
||||
// URLs point at the same gateway the web client uses; override per environment
|
||||
// with EXPO_PUBLIC_API_URL / EXPO_PUBLIC_WS_URL (e.g. a LAN IP for a device).
|
||||
const apiUrl = process.env['EXPO_PUBLIC_API_URL'] ?? 'http://localhost:8080/api';
|
||||
const wsUrl = process.env['EXPO_PUBLIC_WS_URL'] ?? 'ws://localhost:8080/connection/websocket';
|
||||
|
||||
const config: ExpoConfig = {
|
||||
name: 'Zovi',
|
||||
slug: 'zovi',
|
||||
scheme: 'zovi',
|
||||
version: '0.1.0',
|
||||
orientation: 'portrait',
|
||||
userInterfaceStyle: 'automatic',
|
||||
newArchEnabled: true,
|
||||
ios: {
|
||||
supportsTablet: true,
|
||||
bundleIdentifier: 'com.altricade.messenger',
|
||||
},
|
||||
android: {
|
||||
package: 'com.altricade.messenger',
|
||||
edgeToEdgeEnabled: true,
|
||||
},
|
||||
plugins: [
|
||||
'expo-router',
|
||||
'expo-secure-store',
|
||||
['expo-audio', { microphonePermission: 'Zovi uses the microphone to record voice messages.' }],
|
||||
'expo-video',
|
||||
['expo-camera', { cameraPermission: 'Zovi uses the camera for video messages.' }],
|
||||
[
|
||||
'expo-image-picker',
|
||||
{ photosPermission: 'Zovi accesses your photos to share images and videos.' },
|
||||
],
|
||||
'expo-notifications',
|
||||
],
|
||||
experiments: {
|
||||
typedRoutes: true,
|
||||
},
|
||||
extra: {
|
||||
apiUrl,
|
||||
wsUrl,
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
46
packages/mobile/app/(app)/(tabs)/_layout.tsx
Normal file
46
packages/mobile/app/(app)/(tabs)/_layout.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { ReactElement } from 'react';
|
||||
import { Tabs } from 'expo-router';
|
||||
import { Icon } from '@/components';
|
||||
import type { IconName } from '@/components';
|
||||
import { useTheme } from '@/theme';
|
||||
|
||||
// Open on Chats, not the leftmost (Contacts) tab.
|
||||
export const unstable_settings = {
|
||||
initialRouteName: 'index',
|
||||
};
|
||||
|
||||
const tabIcon =
|
||||
(name: IconName) =>
|
||||
({ color, size }: { color: string; size: number }): ReactElement => (
|
||||
<Icon name={name} size={size} color={color} />
|
||||
);
|
||||
|
||||
// Persistent bottom navigation for the authenticated area: Contacts · Chats ·
|
||||
// Settings. Chat/profile/group and modals live in the parent stack so they
|
||||
// cover the tab bar when opened.
|
||||
export default function TabsLayout(): ReactElement {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: colors.accent,
|
||||
tabBarInactiveTintColor: colors.textMuted,
|
||||
tabBarStyle: {
|
||||
backgroundColor: colors.background,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="contacts"
|
||||
options={{ title: 'Contacts', tabBarIcon: tabIcon('user') }}
|
||||
/>
|
||||
<Tabs.Screen name="index" options={{ title: 'Chats', tabBarIcon: tabIcon('chats') }} />
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{ title: 'Settings', tabBarIcon: tabIcon('settings') }}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
6
packages/mobile/app/(app)/(tabs)/contacts.tsx
Normal file
6
packages/mobile/app/(app)/(tabs)/contacts.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { ReactElement } from 'react';
|
||||
import { ContactsScreen } from '@/features/contacts';
|
||||
|
||||
export default function ContactsRoute(): ReactElement {
|
||||
return <ContactsScreen />;
|
||||
}
|
||||
6
packages/mobile/app/(app)/(tabs)/index.tsx
Normal file
6
packages/mobile/app/(app)/(tabs)/index.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { ReactElement } from 'react';
|
||||
import { ChatsScreen } from '@/features/conversations';
|
||||
|
||||
export default function ChatsRoute(): ReactElement {
|
||||
return <ChatsScreen />;
|
||||
}
|
||||
6
packages/mobile/app/(app)/(tabs)/settings.tsx
Normal file
6
packages/mobile/app/(app)/(tabs)/settings.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { ReactElement } from 'react';
|
||||
import { SettingsScreen } from '@/features/settings';
|
||||
|
||||
export default function SettingsRoute(): ReactElement {
|
||||
return <SettingsScreen />;
|
||||
}
|
||||
29
packages/mobile/app/(app)/_layout.tsx
Normal file
29
packages/mobile/app/(app)/_layout.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { ReactElement } from 'react';
|
||||
import { Stack } from 'expo-router';
|
||||
import { RealtimeProvider } from '@/ws';
|
||||
import { useTheme } from '@/theme';
|
||||
import { usePush } from '@/services/usePush';
|
||||
|
||||
// Authenticated area: everything here has a live realtime connection.
|
||||
export default function AppLayout(): ReactElement {
|
||||
const { colors } = useTheme();
|
||||
usePush();
|
||||
return (
|
||||
<RealtimeProvider>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: colors.background },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="chat/[id]" />
|
||||
<Stack.Screen name="profile/[username]" options={{ presentation: 'card' }} />
|
||||
<Stack.Screen name="group/[id]" options={{ presentation: 'card' }} />
|
||||
<Stack.Screen name="new/group" options={{ presentation: 'modal' }} />
|
||||
<Stack.Screen name="new/channel" options={{ presentation: 'modal' }} />
|
||||
<Stack.Screen name="forward" options={{ presentation: 'modal' }} />
|
||||
</Stack>
|
||||
</RealtimeProvider>
|
||||
);
|
||||
}
|
||||
12
packages/mobile/app/(app)/chat/[id].tsx
Normal file
12
packages/mobile/app/(app)/chat/[id].tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { ReactElement } from 'react';
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
import { ChatScreen } from '@/features/messaging';
|
||||
|
||||
export default function ChatRoute(): ReactElement | null {
|
||||
const params = useLocalSearchParams<{ id: string }>();
|
||||
const id = typeof params.id === 'string' ? params.id : null;
|
||||
if (id === null) {
|
||||
return null;
|
||||
}
|
||||
return <ChatScreen conversationId={id} />;
|
||||
}
|
||||
14
packages/mobile/app/(app)/forward.tsx
Normal file
14
packages/mobile/app/(app)/forward.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { ReactElement } from 'react';
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
import { ForwardScreen } from '@/features/messaging';
|
||||
|
||||
export default function ForwardRoute(): ReactElement | null {
|
||||
const params = useLocalSearchParams<{ ids: string; from: string }>();
|
||||
const idsParam = typeof params.ids === 'string' ? params.ids : '';
|
||||
const from = typeof params.from === 'string' ? params.from : null;
|
||||
const messageIds = idsParam.split(',').filter((id) => id.length > 0);
|
||||
if (from === null || messageIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return <ForwardScreen messageIds={messageIds} fromConversationId={from} />;
|
||||
}
|
||||
12
packages/mobile/app/(app)/group/[id].tsx
Normal file
12
packages/mobile/app/(app)/group/[id].tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { ReactElement } from 'react';
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
import { GroupInfoScreen } from '@/features/profile';
|
||||
|
||||
export default function GroupInfoRoute(): ReactElement | null {
|
||||
const params = useLocalSearchParams<{ id: string }>();
|
||||
const id = typeof params.id === 'string' ? params.id : null;
|
||||
if (id === null) {
|
||||
return null;
|
||||
}
|
||||
return <GroupInfoScreen conversationId={id} />;
|
||||
}
|
||||
6
packages/mobile/app/(app)/new/channel.tsx
Normal file
6
packages/mobile/app/(app)/new/channel.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { ReactElement } from 'react';
|
||||
import { NewChatWizard } from '@/features/conversations';
|
||||
|
||||
export default function NewChannelRoute(): ReactElement {
|
||||
return <NewChatWizard mode="channel" />;
|
||||
}
|
||||
6
packages/mobile/app/(app)/new/group.tsx
Normal file
6
packages/mobile/app/(app)/new/group.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { ReactElement } from 'react';
|
||||
import { NewChatWizard } from '@/features/conversations';
|
||||
|
||||
export default function NewGroupRoute(): ReactElement {
|
||||
return <NewChatWizard mode="group" />;
|
||||
}
|
||||
12
packages/mobile/app/(app)/profile/[username].tsx
Normal file
12
packages/mobile/app/(app)/profile/[username].tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { ReactElement } from 'react';
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
import { ProfileScreen } from '@/features/profile';
|
||||
|
||||
export default function ProfileRoute(): ReactElement | null {
|
||||
const params = useLocalSearchParams<{ username: string }>();
|
||||
const username = typeof params.username === 'string' ? params.username : null;
|
||||
if (username === null || username.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return <ProfileScreen username={username} />;
|
||||
}
|
||||
6
packages/mobile/app/(auth)/_layout.tsx
Normal file
6
packages/mobile/app/(auth)/_layout.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { ReactElement } from 'react';
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function AuthLayout(): ReactElement {
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
6
packages/mobile/app/(auth)/sign-in.tsx
Normal file
6
packages/mobile/app/(auth)/sign-in.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { ReactElement } from 'react';
|
||||
import { SignInScreen } from '@/features/auth';
|
||||
|
||||
export default function SignIn(): ReactElement {
|
||||
return <SignInScreen />;
|
||||
}
|
||||
65
packages/mobile/app/_layout.tsx
Normal file
65
packages/mobile/app/_layout.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import 'react-native-gesture-handler';
|
||||
import { useEffect } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { ActivityIndicator, View } from 'react-native';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { KeyboardProvider } from 'react-native-keyboard-controller';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { setAudioModeAsync } from 'expo-audio';
|
||||
import { ThemeProvider, useTheme } from '@/theme';
|
||||
import { useSession } from '@/stores/session';
|
||||
import { ErrorBoundary } from '@/components';
|
||||
|
||||
// Voice notes must play even with the iPhone mute switch on (Telegram
|
||||
// behavior) — without this, playback "works" but is silent on most devices.
|
||||
void setAudioModeAsync({ playsInSilentMode: true });
|
||||
|
||||
const RootNavigator = (): ReactElement => {
|
||||
const { colors, name } = useTheme();
|
||||
const status = useSession((s) => s.status);
|
||||
const bootstrap = useSession((s) => s.bootstrap);
|
||||
|
||||
useEffect(() => {
|
||||
void bootstrap();
|
||||
}, [bootstrap]);
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.background }}>
|
||||
<ActivityIndicator color={colors.accent} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusBar style={name === 'dark' ? 'light' : 'dark'} />
|
||||
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.background } }}>
|
||||
<Stack.Protected guard={status === 'authenticated'}>
|
||||
<Stack.Screen name="(app)" />
|
||||
</Stack.Protected>
|
||||
<Stack.Protected guard={status !== 'authenticated'}>
|
||||
<Stack.Screen name="(auth)" />
|
||||
</Stack.Protected>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default function RootLayout(): ReactElement {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<ErrorBoundary>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<ThemeProvider>
|
||||
<RootNavigator />
|
||||
</ThemeProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
</ErrorBoundary>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
8
packages/mobile/babel.config.js
Normal file
8
packages/mobile/babel.config.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Expo + Reanimated. The worklets plugin (Reanimated 4) MUST be listed last.
|
||||
module.exports = function babel(api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: ['react-native-worklets/plugin'],
|
||||
};
|
||||
};
|
||||
|
|
@ -1,3 +1,33 @@
|
|||
import { base } from '../../eslint.config.mjs';
|
||||
import 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
3
packages/mobile/expo-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/// <reference types="expo/types" />
|
||||
|
||||
// NOTE: This file should not be edited and should be in your git ignore
|
||||
30
packages/mobile/ios/.gitignore
vendored
Normal file
30
packages/mobile/ios/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# OSX
|
||||
#
|
||||
.DS_Store
|
||||
|
||||
# Xcode
|
||||
#
|
||||
build/
|
||||
*.pbxuser
|
||||
!default.pbxuser
|
||||
*.mode1v3
|
||||
!default.mode1v3
|
||||
*.mode2v3
|
||||
!default.mode2v3
|
||||
*.perspectivev3
|
||||
!default.perspectivev3
|
||||
xcuserdata
|
||||
*.xccheckout
|
||||
*.moved-aside
|
||||
DerivedData
|
||||
*.hmap
|
||||
*.ipa
|
||||
*.xcuserstate
|
||||
project.xcworkspace
|
||||
.xcode.env.local
|
||||
|
||||
# Bundle artifacts
|
||||
*.jsbundle
|
||||
|
||||
# CocoaPods
|
||||
/Pods/
|
||||
11
packages/mobile/ios/.xcode.env
Normal file
11
packages/mobile/ios/.xcode.env
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# This `.xcode.env` file is versioned and is used to source the environment
|
||||
# used when running script phases inside Xcode.
|
||||
# To customize your local environment, you can create an `.xcode.env.local`
|
||||
# file that is not versioned.
|
||||
|
||||
# NODE_BINARY variable contains the PATH to the node executable.
|
||||
#
|
||||
# Customize the NODE_BINARY variable here.
|
||||
# For example, to use nvm with brew, add the following line
|
||||
# . "$(brew --prefix nvm)/nvm.sh" --no-use
|
||||
export NODE_BINARY=$(command -v node)
|
||||
63
packages/mobile/ios/Podfile
Normal file
63
packages/mobile/ios/Podfile
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
require File.join(File.dirname(`node --print "require.resolve('expo/package.json')"`), "scripts/autolinking")
|
||||
require File.join(File.dirname(`node --print "require.resolve('react-native/package.json')"`), "scripts/react_native_pods")
|
||||
|
||||
require 'json'
|
||||
podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {}
|
||||
|
||||
def ccache_enabled?(podfile_properties)
|
||||
# Environment variable takes precedence
|
||||
return ENV['USE_CCACHE'] == '1' if ENV['USE_CCACHE']
|
||||
|
||||
# Fall back to Podfile properties
|
||||
podfile_properties['apple.ccacheEnabled'] == 'true'
|
||||
end
|
||||
|
||||
ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if podfile_properties['newArchEnabled'] == 'false'
|
||||
ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] ||= podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR']
|
||||
ENV['RCT_USE_RN_DEP'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' && podfile_properties['newArchEnabled'] != 'false'
|
||||
ENV['RCT_USE_PREBUILT_RNCORE'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' && podfile_properties['newArchEnabled'] != 'false'
|
||||
platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1'
|
||||
|
||||
prepare_react_native_project!
|
||||
|
||||
target 'Zovi' do
|
||||
use_expo_modules!
|
||||
|
||||
if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] == '1'
|
||||
config_command = ['node', '-e', "process.argv=['', '', 'config'];require('@react-native-community/cli').run()"];
|
||||
else
|
||||
config_command = [
|
||||
'node',
|
||||
'--no-warnings',
|
||||
'--eval',
|
||||
'require(\'expo/bin/autolinking\')',
|
||||
'expo-modules-autolinking',
|
||||
'react-native-config',
|
||||
'--json',
|
||||
'--platform',
|
||||
'ios'
|
||||
]
|
||||
end
|
||||
|
||||
config = use_native_modules!(config_command)
|
||||
|
||||
use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks']
|
||||
use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS']
|
||||
|
||||
use_react_native!(
|
||||
:path => config[:reactNativePath],
|
||||
:hermes_enabled => podfile_properties['expo.jsEngine'] == nil || podfile_properties['expo.jsEngine'] == 'hermes',
|
||||
# An absolute path to your application root.
|
||||
:app_path => "#{Pod::Config.instance.installation_root}/..",
|
||||
:privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false',
|
||||
)
|
||||
|
||||
post_install do |installer|
|
||||
react_native_post_install(
|
||||
installer,
|
||||
config[:reactNativePath],
|
||||
:mac_catalyst_enabled => false,
|
||||
:ccache_enabled => ccache_enabled?(podfile_properties),
|
||||
)
|
||||
end
|
||||
end
|
||||
2597
packages/mobile/ios/Podfile.lock
Normal file
2597
packages/mobile/ios/Podfile.lock
Normal file
File diff suppressed because it is too large
Load diff
5
packages/mobile/ios/Podfile.properties.json
Normal file
5
packages/mobile/ios/Podfile.properties.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"expo.jsEngine": "hermes",
|
||||
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
|
||||
"newArchEnabled": "true"
|
||||
}
|
||||
560
packages/mobile/ios/Zovi.xcodeproj/project.pbxproj
Normal file
560
packages/mobile/ios/Zovi.xcodeproj/project.pbxproj
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||
2600D210A644F3099EBCAC60 /* libPods-Zovi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 2F35A3DE0EDD554ECC9665C9 /* libPods-Zovi.a */; };
|
||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
||||
B1BC7EA401DEA33ACBA14386 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 5E79EB555E8560CE37666A91 /* PrivacyInfo.xcprivacy */; };
|
||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
||||
E4F7C222FFFCC78DE13D287F /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5206ED93486F6EE25D62DF8 /* ExpoModulesProvider.swift */; };
|
||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
01B1386BDA70C35F3BB3A395 /* Pods-Zovi.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Zovi.debug.xcconfig"; path = "Target Support Files/Pods-Zovi/Pods-Zovi.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
13B07F961A680F5B00A75B9A /* Zovi.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Zovi.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Zovi/Images.xcassets; sourceTree = "<group>"; };
|
||||
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Zovi/Info.plist; sourceTree = "<group>"; };
|
||||
2F35A3DE0EDD554ECC9665C9 /* libPods-Zovi.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Zovi.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5E79EB555E8560CE37666A91 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Zovi/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
A5206ED93486F6EE25D62DF8 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Zovi/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Zovi/SplashScreen.storyboard; sourceTree = "<group>"; };
|
||||
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
|
||||
DE9441A6824A495DF6F5687D /* Pods-Zovi.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Zovi.release.xcconfig"; path = "Target Support Files/Pods-Zovi/Pods-Zovi.release.xcconfig"; sourceTree = "<group>"; };
|
||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
||||
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Zovi/AppDelegate.swift; sourceTree = "<group>"; };
|
||||
F11748442D0722820044C1D9 /* Zovi-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "Zovi-Bridging-Header.h"; path = "Zovi/Zovi-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
13B07F8C1A680F5B00A75B9A /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
2600D210A644F3099EBCAC60 /* libPods-Zovi.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
13B07FAE1A68108700A75B9A /* Zovi */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
|
||||
F11748442D0722820044C1D9 /* Zovi-Bridging-Header.h */,
|
||||
BB2F792B24A3F905000567C9 /* Supporting */,
|
||||
13B07FB51A68108700A75B9A /* Images.xcassets */,
|
||||
13B07FB61A68108700A75B9A /* Info.plist */,
|
||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
|
||||
5E79EB555E8560CE37666A91 /* PrivacyInfo.xcprivacy */,
|
||||
);
|
||||
name = Zovi;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2D16E6871FA4F8E400B85C8A /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
|
||||
2F35A3DE0EDD554ECC9665C9 /* libPods-Zovi.a */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
68802507543A031D2F8E62A0 /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
01B1386BDA70C35F3BB3A395 /* Pods-Zovi.debug.xcconfig */,
|
||||
DE9441A6824A495DF6F5687D /* Pods-Zovi.release.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
832341AE1AAA6A7D00B99B32 /* Libraries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
name = Libraries;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
83CBB9F61A601CBA00E9B192 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
13B07FAE1A68108700A75B9A /* Zovi */,
|
||||
832341AE1AAA6A7D00B99B32 /* Libraries */,
|
||||
83CBBA001A601CBA00E9B192 /* Products */,
|
||||
2D16E6871FA4F8E400B85C8A /* Frameworks */,
|
||||
68802507543A031D2F8E62A0 /* Pods */,
|
||||
A06D5BE166B78DD90D60B1AC /* ExpoModulesProviders */,
|
||||
);
|
||||
indentWidth = 2;
|
||||
sourceTree = "<group>";
|
||||
tabWidth = 2;
|
||||
usesTabs = 0;
|
||||
};
|
||||
83CBBA001A601CBA00E9B192 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
13B07F961A680F5B00A75B9A /* Zovi.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A06D5BE166B78DD90D60B1AC /* ExpoModulesProviders */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C497067B79F68A595CE42947 /* Zovi */,
|
||||
);
|
||||
name = ExpoModulesProviders;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BB2F792B24A3F905000567C9 /* Supporting */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BB2F792C24A3F905000567C9 /* Expo.plist */,
|
||||
);
|
||||
name = Supporting;
|
||||
path = Zovi/Supporting;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C497067B79F68A595CE42947 /* Zovi */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A5206ED93486F6EE25D62DF8 /* ExpoModulesProvider.swift */,
|
||||
);
|
||||
name = Zovi;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
13B07F861A680F5B00A75B9A /* Zovi */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Zovi" */;
|
||||
buildPhases = (
|
||||
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
|
||||
C7038DBFE2B5A25EBB692046 /* [Expo] Configure project */,
|
||||
13B07F871A680F5B00A75B9A /* Sources */,
|
||||
13B07F8C1A680F5B00A75B9A /* Frameworks */,
|
||||
13B07F8E1A680F5B00A75B9A /* Resources */,
|
||||
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
|
||||
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
|
||||
B720D6E7DDBC52B4CBBC0DB6 /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = Zovi;
|
||||
productName = Zovi;
|
||||
productReference = 13B07F961A680F5B00A75B9A /* Zovi.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
83CBB9F71A601CBA00E9B192 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastUpgradeCheck = 1130;
|
||||
TargetAttributes = {
|
||||
13B07F861A680F5B00A75B9A = {
|
||||
DevelopmentTeam = 75MMB3DXA8;
|
||||
LastSwiftMigration = 1250;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Zovi" */;
|
||||
compatibilityVersion = "Xcode 3.2";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 83CBB9F61A601CBA00E9B192;
|
||||
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
13B07F861A680F5B00A75B9A /* Zovi */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
13B07F8E1A680F5B00A75B9A /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
||||
B1BC7EA401DEA33ACBA14386 /* PrivacyInfo.xcprivacy in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"$(SRCROOT)/.xcode.env",
|
||||
"$(SRCROOT)/.xcode.env.local",
|
||||
);
|
||||
name = "Bundle React Native code and images";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n";
|
||||
};
|
||||
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Zovi-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Zovi/Pods-Zovi-resources.sh",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXApplication/ExpoApplication_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle",
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoApplication_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Zovi/Pods-Zovi-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
B720D6E7DDBC52B4CBBC0DB6 /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Zovi/Pods-Zovi-frameworks.sh",
|
||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React",
|
||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies",
|
||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Zovi/Pods-Zovi-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
C7038DBFE2B5A25EBB692046 /* [Expo] Configure project */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"$(SRCROOT)/.xcode.env",
|
||||
"$(SRCROOT)/.xcode.env.local",
|
||||
"$(SRCROOT)/Zovi/Zovi.entitlements",
|
||||
"$(SRCROOT)/Pods/Target Support Files/Pods-Zovi/expo-configure-project.sh",
|
||||
);
|
||||
name = "[Expo] Configure project";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(SRCROOT)/Pods/Target Support Files/Pods-Zovi/ExpoModulesProvider.swift",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Zovi/expo-configure-project.sh\"\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
13B07F871A680F5B00A75B9A /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
|
||||
E4F7C222FFFCC78DE13D287F /* ExpoModulesProvider.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
13B07F941A680F5B00A75B9A /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 01B1386BDA70C35F3BB3A395 /* Pods-Zovi.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Zovi/Zovi.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 75MMB3DXA8;
|
||||
ENABLE_BITCODE = NO;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"$(inherited)",
|
||||
"FB_SONARKIT_ENABLED=1",
|
||||
);
|
||||
INFOPLIST_FILE = Zovi/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
"-lc++",
|
||||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.altricade.messenger;
|
||||
PRODUCT_NAME = Zovi;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Zovi/Zovi-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
13B07F951A680F5B00A75B9A /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = DE9441A6824A495DF6F5687D /* Pods-Zovi.release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Zovi/Zovi.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 75MMB3DXA8;
|
||||
INFOPLIST_FILE = Zovi/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
"-lc++",
|
||||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.altricade.messenger;
|
||||
PRODUCT_NAME = Zovi;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Zovi/Zovi-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
83CBBA201A601CBA00E9B192 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_SYMBOLS_PRIVATE_EXTERN = NO;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
/usr/lib/swift,
|
||||
"$(inherited)",
|
||||
);
|
||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
||||
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||
USE_HERMES = true;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
83CBBA211A601CBA00E9B192 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = YES;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
/usr/lib/swift,
|
||||
"$(inherited)",
|
||||
);
|
||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/.pnpm/react-native@0.81.4_@babel+core@7.29.7_@types+react@19.1.17_react@19.1.0/node_modules/react-native";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||
USE_HERMES = true;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Zovi" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
13B07F941A680F5B00A75B9A /* Debug */,
|
||||
13B07F951A680F5B00A75B9A /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Zovi" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
83CBBA201A601CBA00E9B192 /* Debug */,
|
||||
83CBBA211A601CBA00E9B192 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1130"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||
BuildableName = "Zovi.app"
|
||||
BlueprintName = "Zovi"
|
||||
ReferencedContainer = "container:Zovi.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "00E356ED1AD99517003FC87E"
|
||||
BuildableName = "ZoviTests.xctest"
|
||||
BlueprintName = "ZoviTests"
|
||||
ReferencedContainer = "container:Zovi.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||
BuildableName = "Zovi.app"
|
||||
BlueprintName = "Zovi"
|
||||
ReferencedContainer = "container:Zovi.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||
BuildableName = "Zovi.app"
|
||||
BlueprintName = "Zovi"
|
||||
ReferencedContainer = "container:Zovi.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
10
packages/mobile/ios/Zovi.xcworkspace/contents.xcworkspacedata
generated
Normal file
10
packages/mobile/ios/Zovi.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:Zovi.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
70
packages/mobile/ios/Zovi/AppDelegate.swift
Normal file
70
packages/mobile/ios/Zovi/AppDelegate.swift
Normal 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 |
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "App-Icon-1024x1024@1x.png",
|
||||
"idiom": "universal",
|
||||
"platform": "ios",
|
||||
"size": "1024x1024"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"version": 1,
|
||||
"author": "expo"
|
||||
}
|
||||
}
|
||||
6
packages/mobile/ios/Zovi/Images.xcassets/Contents.json
Normal file
6
packages/mobile/ios/Zovi/Images.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "expo"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
89
packages/mobile/ios/Zovi/Info.plist
Normal file
89
packages/mobile/ios/Zovi/Info.plist
Normal 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>
|
||||
48
packages/mobile/ios/Zovi/PrivacyInfo.xcprivacy
Normal file
48
packages/mobile/ios/Zovi/PrivacyInfo.xcprivacy
Normal 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>
|
||||
39
packages/mobile/ios/Zovi/SplashScreen.storyboard
Normal file
39
packages/mobile/ios/Zovi/SplashScreen.storyboard
Normal 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>
|
||||
12
packages/mobile/ios/Zovi/Supporting/Expo.plist
Normal file
12
packages/mobile/ios/Zovi/Supporting/Expo.plist
Normal 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>
|
||||
3
packages/mobile/ios/Zovi/Zovi-Bridging-Header.h
Normal file
3
packages/mobile/ios/Zovi/Zovi-Bridging-Header.h
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
//
|
||||
// Use this file to import your target's public headers that you would like to expose to Swift.
|
||||
//
|
||||
8
packages/mobile/ios/Zovi/Zovi.entitlements
Normal file
8
packages/mobile/ios/Zovi/Zovi.entitlements
Normal 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>
|
||||
21
packages/mobile/metro.config.js
Normal file
21
packages/mobile/metro.config.js
Normal 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;
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
34
packages/mobile/src/api/config.ts
Normal file
34
packages/mobile/src/api/config.ts
Normal 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,
|
||||
};
|
||||
9
packages/mobile/src/api/index.ts
Normal file
9
packages/mobile/src/api/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export { apiConfig, apiBaseUrl, wsUrl } from './config';
|
||||
export {
|
||||
getAccessToken,
|
||||
setAccessToken,
|
||||
getRefreshToken,
|
||||
setRefreshToken,
|
||||
loadRefreshToken,
|
||||
clearTokens,
|
||||
} from './token-store';
|
||||
38
packages/mobile/src/api/token-store.ts
Normal file
38
packages/mobile/src/api/token-store.ts
Normal 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);
|
||||
};
|
||||
104
packages/mobile/src/components/ActionSheet.tsx
Normal file
104
packages/mobile/src/components/ActionSheet.tsx
Normal 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 },
|
||||
});
|
||||
81
packages/mobile/src/components/Avatar.tsx
Normal file
81
packages/mobile/src/components/Avatar.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
54
packages/mobile/src/components/ErrorBoundary.tsx
Normal file
54
packages/mobile/src/components/ErrorBoundary.tsx
Normal 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 },
|
||||
});
|
||||
250
packages/mobile/src/components/Icon.tsx
Normal file
250
packages/mobile/src/components/Icon.tsx
Normal 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>
|
||||
);
|
||||
59
packages/mobile/src/components/IconButton.tsx
Normal file
59
packages/mobile/src/components/IconButton.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
35
packages/mobile/src/components/Screen.tsx
Normal file
35
packages/mobile/src/components/Screen.tsx
Normal 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 },
|
||||
});
|
||||
56
packages/mobile/src/components/ScreenHeader.tsx
Normal file
56
packages/mobile/src/components/ScreenHeader.tsx
Normal 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 },
|
||||
});
|
||||
56
packages/mobile/src/components/UserRow.tsx
Normal file
56
packages/mobile/src/components/UserRow.tsx
Normal 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' },
|
||||
});
|
||||
10
packages/mobile/src/components/index.ts
Normal file
10
packages/mobile/src/components/index.ts
Normal 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';
|
||||
157
packages/mobile/src/features/auth/SignInScreen.tsx
Normal file
157
packages/mobile/src/features/auth/SignInScreen.tsx
Normal 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' },
|
||||
});
|
||||
1
packages/mobile/src/features/auth/index.ts
Normal file
1
packages/mobile/src/features/auth/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { SignInScreen } from './SignInScreen';
|
||||
102
packages/mobile/src/features/contacts/ContactsScreen.tsx
Normal file
102
packages/mobile/src/features/contacts/ContactsScreen.tsx
Normal 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 },
|
||||
});
|
||||
1
packages/mobile/src/features/contacts/index.ts
Normal file
1
packages/mobile/src/features/contacts/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { ContactsScreen } from './ContactsScreen';
|
||||
118
packages/mobile/src/features/conversations/ChatsScreen.tsx
Normal file
118
packages/mobile/src/features/conversations/ChatsScreen.tsx
Normal 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 },
|
||||
});
|
||||
139
packages/mobile/src/features/conversations/ConversationRow.tsx
Normal file
139
packages/mobile/src/features/conversations/ConversationRow.tsx
Normal 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' },
|
||||
});
|
||||
170
packages/mobile/src/features/conversations/NewChatWizard.tsx
Normal file
170
packages/mobile/src/features/conversations/NewChatWizard.tsx
Normal 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' },
|
||||
});
|
||||
5
packages/mobile/src/features/conversations/index.ts
Normal file
5
packages/mobile/src/features/conversations/index.ts
Normal 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';
|
||||
228
packages/mobile/src/features/conversations/model.ts
Normal file
228
packages/mobile/src/features/conversations/model.ts
Normal 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)));
|
||||
};
|
||||
81
packages/mobile/src/features/messaging/attachments.ts
Normal file
81
packages/mobile/src/features/messaging/attachments.ts
Normal 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',
|
||||
};
|
||||
};
|
||||
64
packages/mobile/src/features/messaging/events.ts
Normal file
64
packages/mobile/src/features/messaging/events.ts
Normal 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);
|
||||
};
|
||||
5
packages/mobile/src/features/messaging/index.ts
Normal file
5
packages/mobile/src/features/messaging/index.ts
Normal 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';
|
||||
66
packages/mobile/src/features/messaging/media.ts
Normal file
66
packages/mobile/src/features/messaging/media.ts
Normal 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'}`;
|
||||
};
|
||||
455
packages/mobile/src/features/messaging/model.ts
Normal file
455
packages/mobile/src/features/messaging/model.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
22
packages/mobile/src/features/messaging/rows.ts
Normal file
22
packages/mobile/src/features/messaging/rows.ts
Normal 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;
|
||||
};
|
||||
80
packages/mobile/src/features/messaging/ui/ChatHeader.tsx
Normal file
80
packages/mobile/src/features/messaging/ui/ChatHeader.tsx
Normal 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 },
|
||||
});
|
||||
526
packages/mobile/src/features/messaging/ui/ChatScreen.tsx
Normal file
526
packages/mobile/src/features/messaging/ui/ChatScreen.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
137
packages/mobile/src/features/messaging/ui/Composer.tsx
Normal file
137
packages/mobile/src/features/messaging/ui/Composer.tsx
Normal 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' },
|
||||
});
|
||||
51
packages/mobile/src/features/messaging/ui/FileBubble.tsx
Normal file
51
packages/mobile/src/features/messaging/ui/FileBubble.tsx
Normal 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 },
|
||||
});
|
||||
97
packages/mobile/src/features/messaging/ui/ForwardScreen.tsx
Normal file
97
packages/mobile/src/features/messaging/ui/ForwardScreen.tsx
Normal 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' },
|
||||
});
|
||||
|
|
@ -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 },
|
||||
});
|
||||
59
packages/mobile/src/features/messaging/ui/MediaContent.tsx
Normal file
59
packages/mobile/src/features/messaging/ui/MediaContent.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
109
packages/mobile/src/features/messaging/ui/MediaImage.tsx
Normal file
109
packages/mobile/src/features/messaging/ui/MediaImage.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
108
packages/mobile/src/features/messaging/ui/MediaViewer.tsx
Normal file
108
packages/mobile/src/features/messaging/ui/MediaViewer.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
100
packages/mobile/src/features/messaging/ui/MessageActionSheet.tsx
Normal file
100
packages/mobile/src/features/messaging/ui/MessageActionSheet.tsx
Normal 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 },
|
||||
});
|
||||
149
packages/mobile/src/features/messaging/ui/MessageBubble.tsx
Normal file
149
packages/mobile/src/features/messaging/ui/MessageBubble.tsx
Normal 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
Loading…
Reference in a new issue