@mereb/app-messaging 0.0.5 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/gql.d.ts CHANGED
@@ -1 +1 @@
1
- export { ConversationsDocument as CONVERSATIONS, MessagesDocument as MESSAGES, SendMessageDocument as SEND_MESSAGE } from './headless.js';
1
+ export { ConversationsDocument as CONVERSATIONS, MessagesDocument as MESSAGES, SendMessageDocument as SEND_MESSAGE } from './headless';
package/dist/gql.js CHANGED
@@ -1 +1 @@
1
- export { ConversationsDocument as CONVERSATIONS, MessagesDocument as MESSAGES, SendMessageDocument as SEND_MESSAGE } from './headless.js';
1
+ export { ConversationsDocument as CONVERSATIONS, MessagesDocument as MESSAGES, SendMessageDocument as SEND_MESSAGE } from './headless';
@@ -14,6 +14,18 @@ export type MessagingConversation = {
14
14
  unreadCount: number;
15
15
  updatedAt: string;
16
16
  };
17
+ export type MessagingPerson = {
18
+ id: string;
19
+ handle: string;
20
+ displayName: string;
21
+ avatarUrl?: string | null;
22
+ };
23
+ export type MessagingViewer = Pick<MessagingPerson, 'id' | 'handle' | 'displayName'>;
24
+ export type SharedPostMessage = {
25
+ path: string;
26
+ postId?: string;
27
+ url: string;
28
+ };
17
29
  export type MessagingMessagesConnection = {
18
30
  edges: Array<{
19
31
  cursor: string;
@@ -33,6 +45,46 @@ export type ConversationMessagesQueryResult = {
33
45
  export type SendMessageMutationResult = {
34
46
  sendMessage: MessagingMessage;
35
47
  };
36
- export declare const ConversationsDocument: import("@apollo/client").DocumentNode;
37
- export declare const MessagesDocument: import("@apollo/client").DocumentNode;
38
- export declare const SendMessageDocument: import("@apollo/client").DocumentNode;
48
+ export type MessagingViewerQueryResult = {
49
+ me?: MessagingViewer | null;
50
+ };
51
+ export type MessagingConversationParticipantsQueryResult = {
52
+ usersByIds?: MessagingPerson[] | null;
53
+ };
54
+ export type MessagingSearchUser = {
55
+ id: string;
56
+ handle: string;
57
+ displayName: string;
58
+ bio?: string | null;
59
+ avatarUrl?: string | null;
60
+ followersCount: number;
61
+ followingCount: number;
62
+ };
63
+ export type MessagingSearchUsersConnectionQueryResult = {
64
+ searchUsersConnection: {
65
+ edges: Array<{
66
+ cursor: string;
67
+ node: MessagingSearchUser;
68
+ }>;
69
+ pageInfo: {
70
+ endCursor?: string | null;
71
+ hasNextPage: boolean;
72
+ };
73
+ };
74
+ };
75
+ export declare const ConversationsDocument: import("graphql").DocumentNode;
76
+ export declare const MessagesDocument: import("graphql").DocumentNode;
77
+ export declare const SendMessageDocument: import("graphql").DocumentNode;
78
+ export declare const MessagingViewerDocument: import("graphql").DocumentNode;
79
+ export declare const MessagingConversationParticipantsDocument: import("graphql").DocumentNode;
80
+ export declare const MessagingSearchUsersConnectionDocument: import("graphql").DocumentNode;
81
+ export declare function parseMessagingTimestamp(value?: string | null): Date | null;
82
+ export declare function formatMessagingTimestamp(value?: string | null, now?: number): string;
83
+ export declare function parseSharedPostMessageBody(body?: string | null): SharedPostMessage | null;
84
+ export declare function summarizeMessagingBody(body?: string | null): string;
85
+ export declare function mergeMessagingPeople(...groups: Array<ReadonlyArray<MessagingPerson | MessagingViewer | null | undefined>>): MessagingPerson[];
86
+ export declare function buildPeopleDirectory(people: ReadonlyArray<MessagingPerson>): Map<string, MessagingPerson>;
87
+ export declare function resolveConversationParticipant(conversation: Pick<MessagingConversation, 'participantIds'>, viewerId?: string, peopleById?: ReadonlyMap<string, MessagingPerson>): MessagingPerson | null;
88
+ export declare function deriveConversationTitle(conversation: Pick<MessagingConversation, 'title' | 'participantIds' | 'lastMessage'>, viewerId?: string, peopleById?: ReadonlyMap<string, MessagingPerson>): string;
89
+ export declare function deriveMessageSenderName(message: Pick<MessagingMessage, 'senderId' | 'senderName'>, viewerId?: string, peopleById?: ReadonlyMap<string, MessagingPerson>): string;
90
+ export declare function filterMessagingConversations(conversations: ReadonlyArray<MessagingConversation>, query: string, viewerId?: string, peopleById?: ReadonlyMap<string, MessagingPerson>): MessagingConversation[];
package/dist/headless.js CHANGED
@@ -51,3 +51,237 @@ export const SendMessageDocument = gql `
51
51
  }
52
52
  }
53
53
  `;
54
+ export const MessagingViewerDocument = gql `
55
+ query MessagingViewer {
56
+ me {
57
+ id
58
+ handle
59
+ displayName
60
+ }
61
+ }
62
+ `;
63
+ export const MessagingConversationParticipantsDocument = gql `
64
+ query MessagingConversationParticipants($ids: [ID!]!) {
65
+ usersByIds(ids: $ids) {
66
+ id
67
+ handle
68
+ displayName
69
+ avatarUrl
70
+ }
71
+ }
72
+ `;
73
+ export const MessagingSearchUsersConnectionDocument = gql `
74
+ query MessagingSearchUsersConnection($query: String!, $after: String, $limit: Int = 20) {
75
+ searchUsersConnection(query: $query, after: $after, limit: $limit) {
76
+ edges {
77
+ cursor
78
+ node {
79
+ id
80
+ handle
81
+ displayName
82
+ bio
83
+ avatarUrl
84
+ followersCount
85
+ followingCount
86
+ }
87
+ }
88
+ pageInfo {
89
+ endCursor
90
+ hasNextPage
91
+ }
92
+ }
93
+ }
94
+ `;
95
+ const MINUTE = 60 * 1000;
96
+ const DAY = 24 * 60 * MINUTE;
97
+ const SHARED_POST_PREFIX = 'shared a post:';
98
+ function isGenericDirectMessageTitle(value) {
99
+ const normalized = value?.trim().toLowerCase() ?? '';
100
+ return !normalized || normalized === 'direct message' || normalized === 'conversation';
101
+ }
102
+ function inferMessagingPersonFromIdentifier(identifier) {
103
+ const value = identifier?.trim();
104
+ if (!value) {
105
+ return null;
106
+ }
107
+ return {
108
+ id: value,
109
+ handle: value.replace(/^@/, ''),
110
+ displayName: value.replace(/^@/, '')
111
+ };
112
+ }
113
+ function shouldReplacePerson(current, next) {
114
+ if (!current.displayName && next.displayName) {
115
+ return true;
116
+ }
117
+ if (!current.avatarUrl && next.avatarUrl) {
118
+ return true;
119
+ }
120
+ if (current.handle === current.id && next.handle !== next.id) {
121
+ return true;
122
+ }
123
+ return false;
124
+ }
125
+ export function parseMessagingTimestamp(value) {
126
+ if (!value) {
127
+ return null;
128
+ }
129
+ const numeric = Number(value);
130
+ if (!Number.isNaN(numeric) && numeric > 0) {
131
+ const numericDate = new Date(numeric);
132
+ return Number.isNaN(numericDate.getTime()) ? null : numericDate;
133
+ }
134
+ const parsed = new Date(value);
135
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
136
+ }
137
+ export function formatMessagingTimestamp(value, now = Date.now()) {
138
+ const parsed = parseMessagingTimestamp(value);
139
+ if (!parsed) {
140
+ return 'Recently';
141
+ }
142
+ const diff = Math.abs(now - parsed.getTime());
143
+ if (diff < DAY) {
144
+ return parsed.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
145
+ }
146
+ return parsed.toLocaleDateString([], { month: 'short', day: 'numeric' });
147
+ }
148
+ export function parseSharedPostMessageBody(body) {
149
+ const trimmed = body?.trim();
150
+ if (!trimmed || !trimmed.toLowerCase().startsWith(SHARED_POST_PREFIX)) {
151
+ return null;
152
+ }
153
+ const rawUrl = trimmed.slice(SHARED_POST_PREFIX.length).trim();
154
+ if (!rawUrl) {
155
+ return null;
156
+ }
157
+ try {
158
+ const parsedUrl = new URL(rawUrl, 'https://mereb.local');
159
+ const path = `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
160
+ const postMatches = Array.from(parsedUrl.pathname.matchAll(/\/post\/([^/?#]+)/g));
161
+ const postMatch = postMatches.length > 0 ? postMatches[postMatches.length - 1] : undefined;
162
+ return {
163
+ url: parsedUrl.origin === 'https://mereb.local' ? rawUrl : parsedUrl.toString(),
164
+ path,
165
+ postId: postMatch?.[1]
166
+ };
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ }
172
+ export function summarizeMessagingBody(body) {
173
+ if (!body?.trim()) {
174
+ return 'No messages yet';
175
+ }
176
+ return parseSharedPostMessageBody(body) ? 'Shared a post' : body;
177
+ }
178
+ export function mergeMessagingPeople(...groups) {
179
+ const byId = new Map();
180
+ const byHandle = new Map();
181
+ for (const group of groups) {
182
+ for (const person of group) {
183
+ if (!person?.id) {
184
+ continue;
185
+ }
186
+ const candidate = {
187
+ id: person.id,
188
+ handle: person.handle,
189
+ displayName: person.displayName,
190
+ avatarUrl: 'avatarUrl' in person ? (person.avatarUrl ?? null) : null
191
+ };
192
+ const currentById = byId.get(candidate.id);
193
+ if (!currentById || shouldReplacePerson(currentById, candidate)) {
194
+ byId.set(candidate.id, candidate);
195
+ }
196
+ const handleKey = candidate.handle.toLowerCase();
197
+ const currentByHandle = byHandle.get(handleKey);
198
+ if (!currentByHandle || shouldReplacePerson(currentByHandle, candidate)) {
199
+ byHandle.set(handleKey, candidate);
200
+ }
201
+ }
202
+ }
203
+ const merged = new Map();
204
+ for (const person of [...byHandle.values(), ...byId.values()]) {
205
+ merged.set(person.id, person);
206
+ }
207
+ return Array.from(merged.values());
208
+ }
209
+ export function buildPeopleDirectory(people) {
210
+ return new Map(people.map((person) => [person.id, person]));
211
+ }
212
+ export function resolveConversationParticipant(conversation, viewerId, peopleById = new Map()) {
213
+ const participantIds = Array.from(new Set(conversation.participantIds ?? []));
214
+ const otherParticipantIds = viewerId ? participantIds.filter((participantId) => participantId !== viewerId) : participantIds;
215
+ for (const participantId of otherParticipantIds) {
216
+ const resolved = peopleById.get(participantId) ?? inferMessagingPersonFromIdentifier(participantId);
217
+ if (resolved) {
218
+ return resolved;
219
+ }
220
+ }
221
+ return null;
222
+ }
223
+ export function deriveConversationTitle(conversation, viewerId, peopleById = new Map()) {
224
+ const title = conversation.title?.trim();
225
+ if (title && !isGenericDirectMessageTitle(title)) {
226
+ return title;
227
+ }
228
+ const participantIds = Array.from(new Set(conversation.participantIds ?? []));
229
+ const otherParticipantIds = viewerId ? participantIds.filter((participantId) => participantId !== viewerId) : participantIds;
230
+ const participants = otherParticipantIds
231
+ .map((participantId) => peopleById.get(participantId) ?? inferMessagingPersonFromIdentifier(participantId))
232
+ .filter((person) => Boolean(person));
233
+ if (participants.length === 1) {
234
+ return participants[0].displayName;
235
+ }
236
+ if (participants.length > 1) {
237
+ return participants.map((person) => person.displayName).join(', ');
238
+ }
239
+ const senderName = conversation.lastMessage?.senderName?.trim();
240
+ if (senderName && senderName.toLowerCase() !== 'you') {
241
+ return senderName;
242
+ }
243
+ if (otherParticipantIds.length === 1) {
244
+ return otherParticipantIds[0];
245
+ }
246
+ return 'Direct message';
247
+ }
248
+ export function deriveMessageSenderName(message, viewerId, peopleById = new Map()) {
249
+ if (viewerId && message.senderId === viewerId) {
250
+ return 'You';
251
+ }
252
+ const resolved = peopleById.get(message.senderId);
253
+ if (resolved?.displayName) {
254
+ return resolved.displayName;
255
+ }
256
+ const senderName = message.senderName?.trim();
257
+ if (senderName) {
258
+ return senderName;
259
+ }
260
+ return message.senderId || 'User';
261
+ }
262
+ export function filterMessagingConversations(conversations, query, viewerId, peopleById = new Map()) {
263
+ const normalizedQuery = query.trim().toLowerCase();
264
+ if (!normalizedQuery) {
265
+ return Array.from(conversations);
266
+ }
267
+ return conversations.filter((conversation) => {
268
+ const title = deriveConversationTitle(conversation, viewerId, peopleById).toLowerCase();
269
+ if (title.includes(normalizedQuery)) {
270
+ return true;
271
+ }
272
+ const preview = summarizeMessagingBody(conversation.lastMessage?.body).toLowerCase();
273
+ if (preview.includes(normalizedQuery)) {
274
+ return true;
275
+ }
276
+ const participantIds = Array.from(new Set(conversation.participantIds ?? []));
277
+ const otherParticipantIds = viewerId ? participantIds.filter((participantId) => participantId !== viewerId) : participantIds;
278
+ return otherParticipantIds.some((participantId) => {
279
+ const participant = peopleById.get(participantId) ?? inferMessagingPersonFromIdentifier(participantId);
280
+ if (!participant) {
281
+ return false;
282
+ }
283
+ return (participant.displayName.toLowerCase().includes(normalizedQuery) ||
284
+ participant.handle.toLowerCase().includes(normalizedQuery));
285
+ });
286
+ });
287
+ }
package/dist/native.d.ts CHANGED
@@ -1 +1,31 @@
1
- export declare function MessagesScreen(): import("react/jsx-runtime.js").JSX.Element;
1
+ type MessagesScreenProps = {
2
+ auth?: {
3
+ token?: string;
4
+ login?: () => Promise<void>;
5
+ };
6
+ onSelectConversation?: (conversationId: string) => void;
7
+ onCompose?: () => void;
8
+ };
9
+ type ConversationScreenProps = {
10
+ auth?: {
11
+ token?: string;
12
+ login?: () => Promise<void>;
13
+ };
14
+ conversationId: string;
15
+ };
16
+ type ComposeMessageScreenProps = {
17
+ auth?: {
18
+ token?: string;
19
+ login?: () => Promise<void>;
20
+ };
21
+ onCreatedConversation?: (conversationId: string) => void;
22
+ initialUser?: {
23
+ id: string;
24
+ handle: string;
25
+ displayName: string;
26
+ } | null;
27
+ };
28
+ export declare function MessagesScreen({ auth, onSelectConversation, onCompose }: Readonly<MessagesScreenProps>): import("react/jsx-runtime").JSX.Element;
29
+ export declare function ConversationScreen({ auth, conversationId }: Readonly<ConversationScreenProps>): import("react/jsx-runtime").JSX.Element;
30
+ export declare function ComposeMessageScreen({ auth, onCreatedConversation, initialUser }: Readonly<ComposeMessageScreenProps>): import("react/jsx-runtime").JSX.Element;
31
+ export {};
package/dist/native.js CHANGED
@@ -1,23 +1,362 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMutation, useQuery } from '@apollo/client/react';
2
3
  import { useMemo, useState } from 'react';
3
- import { Text, View } from 'react-native';
4
- import { createInMemoryMessagingStore } from './store.js';
5
- export function MessagesScreen() {
6
- const store = useMemo(() => createInMemoryMessagingStore(), []);
7
- const [version, setVersion] = useState(0);
8
- const conversations = store.getConversations();
9
- return (_jsxs(View, { style: { flex: 1, padding: 24, gap: 16 }, children: [_jsx(Text, { style: { fontWeight: '700', fontSize: 18, marginBottom: 8 }, children: "Messaging" }), conversations.map((conversation) => (_jsxs(View, { style: {
10
- padding: 12,
11
- borderRadius: 12,
12
- borderWidth: 1,
13
- borderColor: '#E2E8F0',
14
- backgroundColor: '#FFFFFF',
15
- gap: 4
16
- }, children: [_jsx(Text, { style: { fontWeight: '600', fontSize: 16 }, children: conversation.title }), _jsx(Text, { style: { color: '#475569' }, children: conversation.lastMessage?.body ?? 'No messages yet' }), _jsxs(Text, { style: { color: '#94A3B8', fontSize: 12 }, children: ["Unread: ", conversation.unreadCount] })] }, conversation.id))), _jsx(Text, { onPress: () => {
17
- store.sendMessage(conversations[0]?.id ?? 'conv-1', 'Sent from mobile preview', {
18
- id: 'mobile-user',
19
- name: 'You'
20
- });
21
- setVersion((v) => v + 1);
22
- }, style: { color: '#2563EB', fontWeight: '600' }, children: "Send a quick test message" }), _jsxs(Text, { style: { color: '#94A3B8', fontSize: 12 }, children: ["Version ", version, " \u2022 This preview uses the package's local in-memory store. Web now uses the real messaging service."] })] }));
4
+ import { ActivityIndicator, FlatList, Pressable, RefreshControl, StyleSheet, Text, TextInput, View } from 'react-native';
5
+ import { tokens } from '@mereb/tokens/native';
6
+ import { buildPeopleDirectory, ConversationsDocument, deriveConversationTitle, deriveMessageSenderName, filterMessagingConversations, formatMessagingTimestamp, MessagingConversationParticipantsDocument, MessagingSearchUsersConnectionDocument, MessagingViewerDocument, MessagesDocument, SendMessageDocument, summarizeMessagingBody } from './headless';
7
+ function describeClientError(error, fallback) {
8
+ const message = error instanceof Error ? error.message.trim() : typeof error === 'string' ? error.trim() : '';
9
+ if (!message || message === 'Subgraph errors redacted') {
10
+ return fallback;
11
+ }
12
+ return __DEV__ ? `${fallback} (${message})` : fallback;
23
13
  }
14
+ function collectParticipantIds(participantGroups) {
15
+ const ids = new Set();
16
+ for (const group of participantGroups) {
17
+ for (const participantId of group) {
18
+ if (participantId) {
19
+ ids.add(participantId);
20
+ }
21
+ }
22
+ }
23
+ return Array.from(ids);
24
+ }
25
+ function EmptyState({ title, body }) {
26
+ return (_jsxs(View, { style: styles.emptyState, children: [_jsx(Text, { style: styles.emptyTitle, children: title }), _jsx(Text, { style: styles.emptyBody, children: body })] }));
27
+ }
28
+ export function MessagesScreen({ auth, onSelectConversation, onCompose }) {
29
+ const [query, setQuery] = useState('');
30
+ const [refreshError, setRefreshError] = useState();
31
+ const token = auth?.token;
32
+ const conversationsQuery = useQuery(ConversationsDocument, {
33
+ notifyOnNetworkStatusChange: true,
34
+ skip: !token
35
+ });
36
+ const viewerQuery = useQuery(MessagingViewerDocument, {
37
+ skip: !token
38
+ });
39
+ const participantIds = useMemo(() => collectParticipantIds((conversationsQuery.data?.conversations ?? []).map((conversation) => conversation.participantIds ?? [])), [conversationsQuery.data?.conversations]);
40
+ const participantsQuery = useQuery(MessagingConversationParticipantsDocument, {
41
+ variables: { ids: participantIds },
42
+ skip: !token || participantIds.length === 0
43
+ });
44
+ const peopleById = useMemo(() => buildPeopleDirectory(participantsQuery.data?.usersByIds ?? []), [participantsQuery.data?.usersByIds]);
45
+ const viewerId = viewerQuery.data?.me?.id;
46
+ const conversations = useMemo(() => filterMessagingConversations(conversationsQuery.data?.conversations ?? [], query, viewerId, peopleById), [conversationsQuery.data?.conversations, peopleById, query, viewerId]);
47
+ const handleRefresh = async () => {
48
+ if (!token) {
49
+ await auth?.login?.();
50
+ return;
51
+ }
52
+ try {
53
+ await conversationsQuery.refetch();
54
+ setRefreshError(undefined);
55
+ }
56
+ catch (error) {
57
+ setRefreshError(describeClientError(error, 'We could not refresh conversations right now.'));
58
+ }
59
+ };
60
+ if (!token) {
61
+ return _jsx(EmptyState, { title: "Sign in to continue", body: "Log in to load your conversations and start new chats." });
62
+ }
63
+ if (conversationsQuery.error) {
64
+ return (_jsx(EmptyState, { title: "Failed to load messages", body: describeClientError(conversationsQuery.error, 'The messaging service did not return conversations.') }));
65
+ }
66
+ return (_jsxs(View, { style: styles.screen, children: [_jsxs(View, { style: styles.headerRow, children: [_jsxs(View, { style: styles.headerCopy, children: [_jsx(Text, { style: styles.screenTitle, children: "Messages" }), _jsx(Text, { style: styles.screenSubtitle, children: "Conversations are now backed by the shared messaging service." })] }), _jsx(Pressable, { accessibilityRole: "button", onPress: onCompose, style: styles.inlineButton, children: _jsx(Text, { style: styles.inlineButtonText, children: "New chat" }) })] }), _jsx(TextInput, { value: query, onChangeText: setQuery, placeholder: "Search conversations", style: styles.searchInput }), refreshError ? _jsx(Text, { style: styles.errorText, children: refreshError }) : null, _jsx(FlatList, { data: conversations, keyExtractor: (item) => item.id, refreshControl: _jsx(RefreshControl, { refreshing: conversationsQuery.loading, onRefresh: () => void handleRefresh() }), contentContainerStyle: styles.listContent, renderItem: ({ item }) => {
67
+ const title = deriveConversationTitle(item, viewerId, peopleById);
68
+ return (_jsxs(Pressable, { accessibilityRole: "button", style: styles.card, onPress: () => onSelectConversation?.(item.id), children: [_jsxs(View, { style: styles.rowBetween, children: [_jsx(Text, { style: styles.cardTitle, children: title }), _jsx(Text, { style: styles.mutedText, children: formatMessagingTimestamp(item.updatedAt) })] }), _jsx(Text, { style: styles.cardBody, children: summarizeMessagingBody(item.lastMessage?.body) }), _jsx(Text, { style: styles.metaText, children: item.unreadCount > 0 ? `${item.unreadCount} unread` : 'Up to date' })] }));
69
+ }, ListEmptyComponent: conversationsQuery.loading ? (_jsx(View, { style: styles.inlineState, children: _jsx(ActivityIndicator, {}) })) : (_jsx(EmptyState, { title: "No conversations yet", body: "Start a new chat to send the first message." })) })] }));
70
+ }
71
+ export function ConversationScreen({ auth, conversationId }) {
72
+ const [draft, setDraft] = useState('');
73
+ const [composerError, setComposerError] = useState();
74
+ const token = auth?.token;
75
+ const conversationsQuery = useQuery(ConversationsDocument, {
76
+ skip: !token
77
+ });
78
+ const viewerQuery = useQuery(MessagingViewerDocument, {
79
+ skip: !token
80
+ });
81
+ const messagesQuery = useQuery(MessagesDocument, {
82
+ variables: { conversationId },
83
+ skip: !token
84
+ });
85
+ const [sendMessage, sendState] = useMutation(SendMessageDocument);
86
+ const conversation = useMemo(() => conversationsQuery.data?.conversations.find((item) => item.id === conversationId) ?? null, [conversationId, conversationsQuery.data?.conversations]);
87
+ const participantIds = conversation?.participantIds ?? [];
88
+ const participantsQuery = useQuery(MessagingConversationParticipantsDocument, {
89
+ variables: { ids: participantIds },
90
+ skip: !token || participantIds.length === 0
91
+ });
92
+ const peopleById = useMemo(() => buildPeopleDirectory(participantsQuery.data?.usersByIds ?? []), [participantsQuery.data?.usersByIds]);
93
+ const viewerId = viewerQuery.data?.me?.id;
94
+ const title = conversation ? deriveConversationTitle(conversation, viewerId, peopleById) : 'Conversation';
95
+ const messages = messagesQuery.data?.messages.edges.map((edge) => edge.node) ?? [];
96
+ const handleSend = async () => {
97
+ if (!token) {
98
+ await auth?.login?.();
99
+ return;
100
+ }
101
+ const body = draft.trim();
102
+ if (!body) {
103
+ return;
104
+ }
105
+ try {
106
+ await sendMessage({
107
+ variables: {
108
+ conversationId,
109
+ body
110
+ }
111
+ });
112
+ setDraft('');
113
+ setComposerError(undefined);
114
+ await Promise.all([messagesQuery.refetch(), conversationsQuery.refetch()]);
115
+ }
116
+ catch (error) {
117
+ setComposerError(describeClientError(error, 'We could not send this message.'));
118
+ }
119
+ };
120
+ if (!token) {
121
+ return _jsx(EmptyState, { title: "Sign in to continue", body: "Log in to view this conversation and send messages." });
122
+ }
123
+ if (messagesQuery.error || conversationsQuery.error) {
124
+ return (_jsx(EmptyState, { title: "Conversation unavailable", body: describeClientError(messagesQuery.error ?? conversationsQuery.error, 'The message thread could not be loaded.') }));
125
+ }
126
+ return (_jsxs(View, { style: styles.screen, children: [_jsx(Text, { style: styles.screenTitle, children: title }), _jsx(FlatList, { data: messages, keyExtractor: (item) => item.id, contentContainerStyle: styles.listContent, renderItem: ({ item }) => {
127
+ const isOwn = viewerId && item.senderId === viewerId;
128
+ return (_jsxs(View, { style: [styles.messageBubble, isOwn ? styles.ownBubble : styles.otherBubble], children: [_jsx(Text, { style: styles.messageSender, children: deriveMessageSenderName(item, viewerId, peopleById) }), _jsx(Text, { style: styles.messageBody, children: item.body }), _jsx(Text, { style: styles.messageMeta, children: formatMessagingTimestamp(item.sentAt) })] }));
129
+ }, ListEmptyComponent: messagesQuery.loading ? (_jsx(View, { style: styles.inlineState, children: _jsx(ActivityIndicator, {}) })) : (_jsx(EmptyState, { title: "No messages yet", body: "Send the first message in this conversation." })) }), _jsxs(View, { style: styles.composerRow, children: [_jsx(TextInput, { value: draft, onChangeText: setDraft, placeholder: "Write a message", style: styles.composerInput, multiline: true }), _jsx(Pressable, { accessibilityRole: "button", disabled: sendState.loading || !draft.trim(), onPress: () => void handleSend(), style: [styles.inlineButton, !draft.trim() ? styles.disabledCard : undefined], children: _jsx(Text, { style: styles.inlineButtonText, children: sendState.loading ? 'Sending…' : 'Send' }) })] }), composerError ? _jsx(Text, { style: styles.errorText, children: composerError }) : null] }));
130
+ }
131
+ export function ComposeMessageScreen({ auth, onCreatedConversation, initialUser = null }) {
132
+ const [query, setQuery] = useState('');
133
+ const [draft, setDraft] = useState('');
134
+ const [composerError, setComposerError] = useState();
135
+ const [selectedUser, setSelectedUser] = useState(initialUser);
136
+ const token = auth?.token;
137
+ const viewerQuery = useQuery(MessagingViewerDocument, {
138
+ skip: !token
139
+ });
140
+ const [sendMessage, sendState] = useMutation(SendMessageDocument);
141
+ const searchQuery = useQuery(MessagingSearchUsersConnectionDocument, {
142
+ variables: { query, limit: 10 },
143
+ skip: !token || query.trim().length < 2
144
+ });
145
+ const viewerId = viewerQuery.data?.me?.id;
146
+ const results = useMemo(() => (searchQuery.data?.searchUsersConnection.edges ?? [])
147
+ .map((edge) => edge.node)
148
+ .filter((user) => user.id !== viewerId), [searchQuery.data?.searchUsersConnection.edges, viewerId]);
149
+ const handleCreateConversation = async () => {
150
+ if (!token) {
151
+ await auth?.login?.();
152
+ return;
153
+ }
154
+ if (!selectedUser || !draft.trim()) {
155
+ return;
156
+ }
157
+ try {
158
+ const result = await sendMessage({
159
+ variables: {
160
+ toUserId: selectedUser.id,
161
+ body: draft.trim()
162
+ }
163
+ });
164
+ const nextConversationId = result.data?.sendMessage.conversationId;
165
+ if (nextConversationId) {
166
+ setDraft('');
167
+ setComposerError(undefined);
168
+ onCreatedConversation?.(nextConversationId);
169
+ }
170
+ }
171
+ catch (error) {
172
+ setComposerError(describeClientError(error, 'We could not start this conversation.'));
173
+ }
174
+ };
175
+ if (!token) {
176
+ return _jsx(EmptyState, { title: "Sign in to continue", body: "Log in to search for teammates and start a conversation." });
177
+ }
178
+ return (_jsxs(View, { style: styles.screen, children: [_jsx(Text, { style: styles.screenTitle, children: "New conversation" }), _jsx(TextInput, { value: query, onChangeText: setQuery, placeholder: "Search for a teammate", style: styles.searchInput }), _jsx(FlatList, { data: results, keyExtractor: (item) => item.id, contentContainerStyle: styles.compactList, renderItem: ({ item }) => {
179
+ const selected = selectedUser?.id === item.id;
180
+ return (_jsxs(Pressable, { accessibilityRole: "button", style: [styles.card, selected ? styles.selectedCard : undefined], onPress: () => setSelectedUser({
181
+ id: item.id,
182
+ handle: item.handle,
183
+ displayName: item.displayName
184
+ }), children: [_jsx(Text, { style: styles.cardTitle, children: item.displayName }), _jsxs(Text, { style: styles.mutedText, children: ["@", item.handle] })] }));
185
+ }, ListEmptyComponent: query.trim().length < 2 ? (_jsx(EmptyState, { title: "Search for someone", body: "Enter at least two characters to find a user." })) : searchQuery.error ? (_jsx(Text, { style: styles.errorText, children: describeClientError(searchQuery.error, 'We could not search for teammates right now.') })) : searchQuery.loading ? (_jsx(View, { style: styles.inlineState, children: _jsx(ActivityIndicator, {}) })) : (_jsx(EmptyState, { title: "No users found", body: "Try a different handle or display name." })) }), selectedUser ? (_jsxs(View, { style: styles.selectionCard, children: [_jsxs(Text, { style: styles.cardTitle, children: ["To: ", selectedUser.displayName] }), _jsxs(Text, { style: styles.mutedText, children: ["@", selectedUser.handle] })] })) : null, _jsxs(View, { style: styles.composerColumn, children: [_jsx(TextInput, { value: draft, onChangeText: setDraft, placeholder: "Write the first message", style: styles.composerInput, multiline: true }), _jsx(Pressable, { accessibilityRole: "button", disabled: sendState.loading || !selectedUser || !draft.trim(), onPress: () => void handleCreateConversation(), style: [
186
+ styles.inlineButton,
187
+ !selectedUser || !draft.trim() ? styles.disabledCard : undefined
188
+ ], children: _jsx(Text, { style: styles.inlineButtonText, children: sendState.loading ? 'Starting…' : 'Start conversation' }) })] }), composerError ? _jsx(Text, { style: styles.errorText, children: composerError }) : null] }));
189
+ }
190
+ const { color, radius, shadow, spacing } = tokens;
191
+ const styles = StyleSheet.create({
192
+ screen: {
193
+ flex: 1,
194
+ backgroundColor: color.surfaceAlt,
195
+ padding: spacing.lg,
196
+ gap: spacing.md
197
+ },
198
+ headerRow: {
199
+ flexDirection: 'row',
200
+ justifyContent: 'space-between',
201
+ alignItems: 'center',
202
+ gap: spacing.md
203
+ },
204
+ headerCopy: {
205
+ flex: 1,
206
+ gap: spacing.xs
207
+ },
208
+ screenTitle: {
209
+ fontSize: 22,
210
+ fontWeight: '700',
211
+ color: color.text
212
+ },
213
+ screenSubtitle: {
214
+ color: color.textMuted,
215
+ lineHeight: 20
216
+ },
217
+ searchInput: {
218
+ borderWidth: 1,
219
+ borderColor: color.borderStrong,
220
+ borderRadius: radius.md,
221
+ paddingHorizontal: spacing.md,
222
+ paddingVertical: spacing.sm,
223
+ backgroundColor: color.surface,
224
+ color: color.text
225
+ },
226
+ listContent: {
227
+ gap: spacing.md,
228
+ paddingBottom: spacing.lg
229
+ },
230
+ compactList: {
231
+ gap: spacing.sm,
232
+ paddingBottom: spacing.md
233
+ },
234
+ card: {
235
+ borderRadius: radius.lg,
236
+ borderWidth: 1,
237
+ borderColor: color.border,
238
+ backgroundColor: color.surface,
239
+ padding: spacing.md,
240
+ gap: spacing.xs,
241
+ ...shadow.sm
242
+ },
243
+ selectedCard: {
244
+ borderColor: color.primary
245
+ },
246
+ selectionCard: {
247
+ borderRadius: radius.lg,
248
+ borderWidth: 1,
249
+ borderColor: color.primary,
250
+ backgroundColor: color.surface,
251
+ padding: spacing.md,
252
+ gap: spacing.xs
253
+ },
254
+ rowBetween: {
255
+ flexDirection: 'row',
256
+ justifyContent: 'space-between',
257
+ gap: spacing.md
258
+ },
259
+ cardTitle: {
260
+ flex: 1,
261
+ fontSize: 16,
262
+ fontWeight: '600',
263
+ color: color.text
264
+ },
265
+ cardBody: {
266
+ color: color.text,
267
+ lineHeight: 20
268
+ },
269
+ metaText: {
270
+ color: color.textSubdued,
271
+ fontSize: 12
272
+ },
273
+ mutedText: {
274
+ color: color.textMuted
275
+ },
276
+ errorText: {
277
+ color: '#d22c2c'
278
+ },
279
+ emptyState: {
280
+ paddingVertical: spacing.xl,
281
+ alignItems: 'center',
282
+ gap: spacing.xs
283
+ },
284
+ emptyTitle: {
285
+ fontSize: 18,
286
+ fontWeight: '600',
287
+ color: color.text
288
+ },
289
+ emptyBody: {
290
+ color: color.textMuted,
291
+ textAlign: 'center',
292
+ lineHeight: 20
293
+ },
294
+ inlineState: {
295
+ paddingVertical: spacing.lg,
296
+ alignItems: 'center'
297
+ },
298
+ inlineButton: {
299
+ borderRadius: 999,
300
+ backgroundColor: color.primary,
301
+ paddingHorizontal: spacing.md,
302
+ paddingVertical: spacing.sm,
303
+ alignItems: 'center',
304
+ justifyContent: 'center'
305
+ },
306
+ inlineButtonText: {
307
+ color: '#ffffff',
308
+ fontWeight: '600'
309
+ },
310
+ disabledCard: {
311
+ opacity: 0.5
312
+ },
313
+ messageBubble: {
314
+ borderRadius: radius.lg,
315
+ padding: spacing.md,
316
+ gap: spacing.xs,
317
+ maxWidth: '88%'
318
+ },
319
+ ownBubble: {
320
+ alignSelf: 'flex-end',
321
+ backgroundColor: color.primary
322
+ },
323
+ otherBubble: {
324
+ alignSelf: 'flex-start',
325
+ backgroundColor: color.surface,
326
+ borderWidth: 1,
327
+ borderColor: color.border
328
+ },
329
+ messageSender: {
330
+ fontSize: 12,
331
+ fontWeight: '600',
332
+ color: color.text
333
+ },
334
+ messageBody: {
335
+ color: color.text,
336
+ lineHeight: 20
337
+ },
338
+ messageMeta: {
339
+ color: color.textMuted,
340
+ fontSize: 12
341
+ },
342
+ composerRow: {
343
+ flexDirection: 'row',
344
+ alignItems: 'flex-end',
345
+ gap: spacing.sm
346
+ },
347
+ composerColumn: {
348
+ gap: spacing.sm
349
+ },
350
+ composerInput: {
351
+ flex: 1,
352
+ minHeight: 48,
353
+ maxHeight: 140,
354
+ borderWidth: 1,
355
+ borderColor: color.borderStrong,
356
+ borderRadius: radius.md,
357
+ paddingHorizontal: spacing.md,
358
+ paddingVertical: spacing.sm,
359
+ backgroundColor: color.surface,
360
+ color: color.text
361
+ }
362
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mereb/app-messaging",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "Messaging experience primitives for Mereb applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -36,15 +36,18 @@
36
36
  "dist",
37
37
  "package.json"
38
38
  ],
39
+ "dependencies": {
40
+ "@mereb/tokens": "^0.0.9"
41
+ },
39
42
  "peerDependencies": {
40
43
  "expo-router": ">=3.0.0",
41
44
  "react": ">=18.2.0",
42
45
  "react-native": ">=0.72.0",
43
- "@apollo/client": ">=3.0.0",
46
+ "@apollo/client": ">=4.0.0",
44
47
  "graphql": ">=16.0.0"
45
48
  },
46
49
  "devDependencies": {
47
- "@apollo/client": "^3.12.5",
50
+ "@apollo/client": "^4.0.9",
48
51
  "@types/react": "~18.2.79",
49
52
  "graphql": "^16.11.0",
50
53
  "husky": "^9.1.7",