@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 +1 -1
- package/dist/gql.js +1 -1
- package/dist/headless.d.ts +55 -3
- package/dist/headless.js +234 -0
- package/dist/native.d.ts +31 -1
- package/dist/native.js +359 -20
- package/package.json +6 -3
package/dist/gql.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { ConversationsDocument as CONVERSATIONS, MessagesDocument as MESSAGES, SendMessageDocument as SEND_MESSAGE } from './headless
|
|
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
|
|
1
|
+
export { ConversationsDocument as CONVERSATIONS, MessagesDocument as MESSAGES, SendMessageDocument as SEND_MESSAGE } from './headless';
|
package/dist/headless.d.ts
CHANGED
|
@@ -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
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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 {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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.
|
|
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": ">=
|
|
46
|
+
"@apollo/client": ">=4.0.0",
|
|
44
47
|
"graphql": ">=16.0.0"
|
|
45
48
|
},
|
|
46
49
|
"devDependencies": {
|
|
47
|
-
"@apollo/client": "^
|
|
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",
|