@oxyhq/services 26.2.0 → 27.0.0
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/lib/commonjs/index.js +21 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ui/components/authChooser/requestSurfaces.js +14 -6
- package/lib/commonjs/ui/components/authChooser/requestSurfaces.js.map +1 -1
- package/lib/commonjs/ui/hooks/mutations/useAccountMutations.js +16 -3
- package/lib/commonjs/ui/hooks/mutations/useAccountMutations.js.map +1 -1
- package/lib/commonjs/ui/hooks/queries/userCache.js +164 -10
- package/lib/commonjs/ui/hooks/queries/userCache.js.map +1 -1
- package/lib/commonjs/ui/screens/AccountSettingsScreen.js +6 -1
- package/lib/commonjs/ui/screens/AccountSettingsScreen.js.map +1 -1
- package/lib/commonjs/ui/screens/CreateAccountScreen.js +73 -40
- package/lib/commonjs/ui/screens/CreateAccountScreen.js.map +1 -1
- package/lib/commonjs/ui/utils/avatarUtils.js +5 -0
- package/lib/commonjs/ui/utils/avatarUtils.js.map +1 -1
- package/lib/module/index.js +4 -2
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/components/authChooser/requestSurfaces.js +15 -7
- package/lib/module/ui/components/authChooser/requestSurfaces.js.map +1 -1
- package/lib/module/ui/hooks/mutations/useAccountMutations.js +16 -3
- package/lib/module/ui/hooks/mutations/useAccountMutations.js.map +1 -1
- package/lib/module/ui/hooks/queries/userCache.js +161 -10
- package/lib/module/ui/hooks/queries/userCache.js.map +1 -1
- package/lib/module/ui/screens/AccountSettingsScreen.js +6 -1
- package/lib/module/ui/screens/AccountSettingsScreen.js.map +1 -1
- package/lib/module/ui/screens/CreateAccountScreen.js +74 -41
- package/lib/module/ui/screens/CreateAccountScreen.js.map +1 -1
- package/lib/module/ui/utils/avatarUtils.js +5 -0
- package/lib/module/ui/utils/avatarUtils.js.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +2 -2
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/authChooser/requestSurfaces.d.ts +6 -1
- package/lib/typescript/commonjs/ui/components/authChooser/requestSurfaces.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +1 -1
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/queries/userCache.d.ts +79 -2
- package/lib/typescript/commonjs/ui/hooks/queries/userCache.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/AccountSettingsScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/CreateAccountScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/avatarUtils.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +2 -2
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/authChooser/requestSurfaces.d.ts +6 -1
- package/lib/typescript/module/ui/components/authChooser/requestSurfaces.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +1 -1
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/queries/userCache.d.ts +79 -2
- package/lib/typescript/module/ui/hooks/queries/userCache.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/AccountSettingsScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/CreateAccountScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/avatarUtils.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/index.ts +15 -3
- package/src/ui/components/authChooser/requestSurfaces.tsx +12 -4
- package/src/ui/hooks/mutations/useAccountMutations.ts +20 -3
- package/src/ui/hooks/queries/__tests__/userCacheClear.test.ts +301 -0
- package/src/ui/hooks/queries/userCache.ts +211 -10
- package/src/ui/screens/AccountSettingsScreen.tsx +9 -1
- package/src/ui/screens/CreateAccountScreen.tsx +66 -36
- package/src/ui/utils/avatarUtils.ts +9 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* userCache — DELIBERATE CLEARS vs sparse sources.
|
|
3
|
+
*
|
|
4
|
+
* `upsertCachedUser` refuses an empty incoming value so a sparse feed/post/list
|
|
5
|
+
* author can never blank a field the authoritative profile fetch stored. That is
|
|
6
|
+
* right for a projection and wrong for a user who just removed their picture,
|
|
7
|
+
* and the payload cannot tell the two apart: oxy-api's `formatUserResponse`
|
|
8
|
+
* emits a cleared field as `undefined`, which `JSON.stringify` drops, so a
|
|
9
|
+
* cleared avatar and an uncarried one arrive byte-identical. The caller declares
|
|
10
|
+
* the difference with `{ cleared: [...] }`.
|
|
11
|
+
*
|
|
12
|
+
* WHAT MAKES THIS SUITE NON-VACUOUS
|
|
13
|
+
* ---------------------------------
|
|
14
|
+
* Every assertion here is paired: the SAME incoming payload is upserted once
|
|
15
|
+
* WITHOUT `cleared` and once WITH it, and the two must disagree. A suite whose
|
|
16
|
+
* fixtures only ever passed `cleared` could not tell "the guard was removed"
|
|
17
|
+
* from "the clear works", which is the whole risk — dropping the guard would
|
|
18
|
+
* blank real avatars across every Oxy app. The `does not` half of each pair is
|
|
19
|
+
* what pins the guard; `userCache.test.ts` keeps its own anti-degradation
|
|
20
|
+
* coverage independently.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { QueryClient } from '@tanstack/react-query';
|
|
24
|
+
import {
|
|
25
|
+
CLEARABLE_USER_FIELDS,
|
|
26
|
+
clearedFieldsFromAccountUpdate,
|
|
27
|
+
clearedFieldsFromProfileUpdate,
|
|
28
|
+
upsertCachedUser,
|
|
29
|
+
upsertCachedUsers,
|
|
30
|
+
} from '../userCache';
|
|
31
|
+
import type { CacheableUser, ClearableUserField } from '../userCache';
|
|
32
|
+
import { queryKeys } from '../queryKeys';
|
|
33
|
+
import { useAuthStore } from '../../../stores/authStore';
|
|
34
|
+
|
|
35
|
+
function makeClient(): QueryClient {
|
|
36
|
+
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readById(qc: QueryClient, id: string): CacheableUser | undefined {
|
|
40
|
+
return qc.getQueryData<CacheableUser>(queryKeys.users.detail(id));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readByUsername(
|
|
44
|
+
qc: QueryClient,
|
|
45
|
+
username: string,
|
|
46
|
+
viewerId: string,
|
|
47
|
+
): CacheableUser | undefined {
|
|
48
|
+
return qc.getQueryData<CacheableUser>(
|
|
49
|
+
queryKeys.users.byUsername(username, viewerId),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A warm, fully-populated entry — what an authoritative profile fetch stored. */
|
|
54
|
+
function seedFullEntry(qc: QueryClient, viewerId = ''): void {
|
|
55
|
+
const full = {
|
|
56
|
+
id: 'u1',
|
|
57
|
+
username: 'alice',
|
|
58
|
+
name: { displayName: 'Alice A', first: 'Alice' },
|
|
59
|
+
avatar: 'file_old',
|
|
60
|
+
bio: 'old bio',
|
|
61
|
+
description: 'old description',
|
|
62
|
+
color: 'teal',
|
|
63
|
+
accountCategories: ['agency'],
|
|
64
|
+
_count: { followers: 10, following: 5 },
|
|
65
|
+
};
|
|
66
|
+
qc.setQueryData(queryKeys.users.detail('u1'), full);
|
|
67
|
+
qc.setQueryData(queryKeys.users.byUsername('alice', viewerId), {
|
|
68
|
+
...full,
|
|
69
|
+
relationship: { isFollowing: true, followsYou: true },
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The write response for an account that just emptied everything, in the shape
|
|
75
|
+
* oxy-api's `formatUserResponse` actually produces — MEASURED, not assumed:
|
|
76
|
+
*
|
|
77
|
+
* {"id":"acc1","publicKey":"pk","username":"chan","name":{},"languages":[]}
|
|
78
|
+
*
|
|
79
|
+
* The two details that matter and are easy to get wrong: every cleared scalar is
|
|
80
|
+
* OMITTED (serialized as `undefined`, which `JSON.stringify` drops), while
|
|
81
|
+
* `name` survives as a PRESENT-but-EMPTY object. Writing this fixture as
|
|
82
|
+
* `{ id, username }` instead — with no `name` key — routes `mergeName` down its
|
|
83
|
+
* "incoming absent" branch and leaves the "incoming present, displayName empty"
|
|
84
|
+
* branch untested; mutation-testing caught exactly that. Nothing in this object
|
|
85
|
+
* says "cleared", which is the whole point.
|
|
86
|
+
*/
|
|
87
|
+
const clearedResponse: CacheableUser = { id: 'u1', username: 'alice', name: {} };
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The same clear expressed the way a caller echoing its own REQUEST would send
|
|
91
|
+
* it: `UserProfileUpdate` clears a display name with `''`. It must land on the
|
|
92
|
+
* same result as the response shape above.
|
|
93
|
+
*/
|
|
94
|
+
const clearedByEmptyString: CacheableUser = {
|
|
95
|
+
id: 'u1',
|
|
96
|
+
username: 'alice',
|
|
97
|
+
name: { displayName: '' },
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
useAuthStore.setState({ user: null });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('upsertCachedUser — a declared clear empties the field', () => {
|
|
105
|
+
it.each(
|
|
106
|
+
CLEARABLE_USER_FIELDS.filter(
|
|
107
|
+
(field): field is Exclude<ClearableUserField, 'name.displayName'> =>
|
|
108
|
+
field !== 'name.displayName',
|
|
109
|
+
),
|
|
110
|
+
)('drops a stale `%s` only when the caller declares it cleared', (field) => {
|
|
111
|
+
// WITHOUT the declaration: the guard holds, the stored value survives.
|
|
112
|
+
const guarded = makeClient();
|
|
113
|
+
seedFullEntry(guarded);
|
|
114
|
+
upsertCachedUser(guarded, clearedResponse, '');
|
|
115
|
+
expect(readById(guarded, 'u1')?.[field]).toBeDefined();
|
|
116
|
+
|
|
117
|
+
// WITH it: the same payload now empties exactly that field.
|
|
118
|
+
const cleared = makeClient();
|
|
119
|
+
seedFullEntry(cleared);
|
|
120
|
+
upsertCachedUser(cleared, clearedResponse, '', { cleared: [field] });
|
|
121
|
+
expect(readById(cleared, 'u1')?.[field]).toBeUndefined();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Three `name` shapes, because `mergeName` branches on them and a single
|
|
125
|
+
// fixture leaves one branch untested (mutation-verified): the measured
|
|
126
|
+
// response carries `name: {}`, a request echo carries `name: {displayName:''}`,
|
|
127
|
+
// and a caller passing a bare object carries no `name` key at all.
|
|
128
|
+
it.each([
|
|
129
|
+
['present but empty (the measured oxy-api shape)', clearedResponse],
|
|
130
|
+
['an explicit empty displayName (the request shape)', clearedByEmptyString],
|
|
131
|
+
['no `name` key at all', { id: 'u1', username: 'alice' } as CacheableUser],
|
|
132
|
+
])(
|
|
133
|
+
'drops a stale display name only when declared — incoming name %s',
|
|
134
|
+
(_label, incoming) => {
|
|
135
|
+
const guarded = makeClient();
|
|
136
|
+
seedFullEntry(guarded);
|
|
137
|
+
upsertCachedUser(guarded, incoming, '');
|
|
138
|
+
expect(readById(guarded, 'u1')?.name).toEqual({
|
|
139
|
+
displayName: 'Alice A',
|
|
140
|
+
first: 'Alice',
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const cleared = makeClient();
|
|
144
|
+
seedFullEntry(cleared);
|
|
145
|
+
upsertCachedUser(cleared, incoming, '', {
|
|
146
|
+
cleared: ['name.displayName'],
|
|
147
|
+
});
|
|
148
|
+
// `first` is not what was cleared and must survive — a clear is per-field,
|
|
149
|
+
// not "drop the whole `name` object".
|
|
150
|
+
expect(readById(cleared, 'u1')?.name).toEqual({ first: 'Alice' });
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
it('never lets the "Unknown user" sentinel survive a declared display-name clear', () => {
|
|
155
|
+
// The ghost-author sentinel is not a real value, so it must not block the
|
|
156
|
+
// clear either — it is "empty" for this purpose, exactly as it is for the
|
|
157
|
+
// anti-degradation guard.
|
|
158
|
+
const qc = makeClient();
|
|
159
|
+
seedFullEntry(qc);
|
|
160
|
+
upsertCachedUser(
|
|
161
|
+
qc,
|
|
162
|
+
{ id: 'u1', username: 'alice', name: { displayName: 'Unknown user' } },
|
|
163
|
+
'',
|
|
164
|
+
{ cleared: ['name.displayName'] },
|
|
165
|
+
);
|
|
166
|
+
expect(readById(qc, 'u1')?.name).toEqual({ first: 'Alice' });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('clears under the by-username key too, not just by-id', () => {
|
|
170
|
+
const qc = makeClient();
|
|
171
|
+
seedFullEntry(qc, 'viewer-1');
|
|
172
|
+
upsertCachedUser(qc, clearedResponse, 'viewer-1', { cleared: ['avatar'] });
|
|
173
|
+
|
|
174
|
+
expect(readByUsername(qc, 'alice', 'viewer-1')?.avatar).toBeUndefined();
|
|
175
|
+
expect(readById(qc, 'u1')?.avatar).toBeUndefined();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('clears ONLY the declared fields, leaving every other one intact', () => {
|
|
179
|
+
const qc = makeClient();
|
|
180
|
+
seedFullEntry(qc);
|
|
181
|
+
upsertCachedUser(qc, clearedResponse, '', { cleared: ['avatar'] });
|
|
182
|
+
|
|
183
|
+
const entry = readById(qc, 'u1');
|
|
184
|
+
expect(entry?.avatar).toBeUndefined();
|
|
185
|
+
// Vacuity floor: if a clear were implemented as "drop everything absent
|
|
186
|
+
// from the payload", all of these would be gone too and the test above
|
|
187
|
+
// would still pass.
|
|
188
|
+
expect(entry?.bio).toBe('old bio');
|
|
189
|
+
expect(entry?.description).toBe('old description');
|
|
190
|
+
expect(entry?.color).toBe('teal');
|
|
191
|
+
expect(entry?.name).toEqual({ displayName: 'Alice A', first: 'Alice' });
|
|
192
|
+
expect(entry?._count).toEqual({ followers: 10, following: 5 });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('never drops the viewer relationship, whatever is declared', () => {
|
|
196
|
+
// `relationship` is not declarable (it is absent from CLEARABLE_USER_FIELDS)
|
|
197
|
+
// and is server-derived, never user-emptied. Dropping it is the
|
|
198
|
+
// "Follows you tag vanishes" bug this module exists to prevent.
|
|
199
|
+
const qc = makeClient();
|
|
200
|
+
seedFullEntry(qc, 'viewer-1');
|
|
201
|
+
upsertCachedUser(qc, clearedResponse, 'viewer-1', {
|
|
202
|
+
cleared: [...CLEARABLE_USER_FIELDS],
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
expect(readByUsername(qc, 'alice', 'viewer-1')?.relationship).toEqual({
|
|
206
|
+
isFollowing: true,
|
|
207
|
+
followsYou: true,
|
|
208
|
+
});
|
|
209
|
+
expect(readByUsername(qc, 'alice', 'viewer-1')?.username).toBe('alice');
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('upsertCachedUser — a declared clear never beats a real value', () => {
|
|
214
|
+
it('keeps a meaningful incoming value for a field declared cleared', () => {
|
|
215
|
+
// The server-COMPOSED display name case: clearing the EXPLICIT name makes
|
|
216
|
+
// oxy-api return the composed one, which must be stored rather than
|
|
217
|
+
// blanked. Same rule for every field: `cleared` lowers the guard, it does
|
|
218
|
+
// not force an erasure.
|
|
219
|
+
const qc = makeClient();
|
|
220
|
+
seedFullEntry(qc);
|
|
221
|
+
|
|
222
|
+
upsertCachedUser(
|
|
223
|
+
qc,
|
|
224
|
+
{
|
|
225
|
+
id: 'u1',
|
|
226
|
+
username: 'alice',
|
|
227
|
+
name: { displayName: 'Alice Anderson' },
|
|
228
|
+
avatar: 'file_new',
|
|
229
|
+
},
|
|
230
|
+
'',
|
|
231
|
+
{ cleared: ['name.displayName', 'avatar'] },
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
expect(readById(qc, 'u1')?.name).toEqual({
|
|
235
|
+
displayName: 'Alice Anderson',
|
|
236
|
+
first: 'Alice',
|
|
237
|
+
});
|
|
238
|
+
expect(readById(qc, 'u1')?.avatar).toBe('file_new');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('treats an explicit null and an empty string as cleared, not as values', () => {
|
|
242
|
+
// oxy-api omits a cleared field, but `UpdateAccountInput` clears with `null`
|
|
243
|
+
// and `UserProfileUpdate` clears with `''`, so a caller echoing its own
|
|
244
|
+
// request must land on the same result as one passing the response.
|
|
245
|
+
for (const empty of [null, '', ' ']) {
|
|
246
|
+
const qc = makeClient();
|
|
247
|
+
seedFullEntry(qc);
|
|
248
|
+
upsertCachedUser(qc, { id: 'u1', username: 'alice', avatar: empty }, '', {
|
|
249
|
+
cleared: ['avatar'],
|
|
250
|
+
});
|
|
251
|
+
expect(readById(qc, 'u1')?.avatar).toBeUndefined();
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('is a no-op on a cold slot (nothing stale to drop)', () => {
|
|
256
|
+
const qc = makeClient();
|
|
257
|
+
upsertCachedUser(qc, clearedResponse, '', { cleared: ['avatar'] });
|
|
258
|
+
|
|
259
|
+
expect(readById(qc, 'u1')).toMatchObject({ id: 'u1', username: 'alice' });
|
|
260
|
+
expect(readById(qc, 'u1')?.avatar).toBeUndefined();
|
|
261
|
+
// The cold-slot stale-seed contract still holds.
|
|
262
|
+
expect(qc.getQueryState(queryKeys.users.detail('u1'))?.dataUpdatedAt).toBe(0);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe('upsertCachedUsers — the batch path stays guarded', () => {
|
|
267
|
+
it('takes no clear declaration and never empties a field', () => {
|
|
268
|
+
// A batch is a multi-user projection: exactly the sparse source the guard
|
|
269
|
+
// exists for. If it ever grows a `cleared` option this test should fail.
|
|
270
|
+
const qc = makeClient();
|
|
271
|
+
seedFullEntry(qc);
|
|
272
|
+
|
|
273
|
+
upsertCachedUsers(qc, [{ id: 'u1', username: 'alice', avatar: null }], '');
|
|
274
|
+
|
|
275
|
+
expect(readById(qc, 'u1')?.avatar).toBe('file_old');
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe('clearedFieldsFromProfileUpdate', () => {
|
|
280
|
+
it('names only fields the patch deliberately emptied', () => {
|
|
281
|
+
expect(
|
|
282
|
+
clearedFieldsFromProfileUpdate({ avatar: '', bio: '', name: { displayName: '' } }),
|
|
283
|
+
).toEqual(['avatar', 'bio', 'name.displayName']);
|
|
284
|
+
expect(clearedFieldsFromProfileUpdate({ avatar: 'file_new', bio: 'hello' })).toEqual([]);
|
|
285
|
+
expect(clearedFieldsFromProfileUpdate({ color: null })).toEqual(['color']);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe('clearedFieldsFromAccountUpdate', () => {
|
|
290
|
+
it('treats null clears on managed accounts like profile clears', () => {
|
|
291
|
+
expect(
|
|
292
|
+
clearedFieldsFromAccountUpdate({
|
|
293
|
+
avatar: null,
|
|
294
|
+
bio: null,
|
|
295
|
+
accountCategories: [],
|
|
296
|
+
name: { displayName: '' },
|
|
297
|
+
}),
|
|
298
|
+
).toEqual(['avatar', 'bio', 'accountCategories', 'name.displayName']);
|
|
299
|
+
expect(clearedFieldsFromAccountUpdate({ bio: 'still here' })).toEqual([]);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
@@ -36,10 +36,38 @@
|
|
|
36
36
|
* never overwritten by a degraded/empty one (empty username, the
|
|
37
37
|
* `'Unknown user'` ghost-author sentinel, `null` avatar).
|
|
38
38
|
*
|
|
39
|
+
* EXPRESSING A DELIBERATE CLEAR ("remove my picture")
|
|
40
|
+
* ---------------------------------------------------
|
|
41
|
+
* The anti-degradation rule above is right for a sparse source and wrong for a
|
|
42
|
+
* user who just emptied the field — and the two are NOT distinguishable from the
|
|
43
|
+
* payload. Measured against oxy-api's canonical serializer (`formatUserResponse`
|
|
44
|
+
* in `packages/api/src/utils/userTransform.ts`, which passes every field through
|
|
45
|
+
* `typeof value === 'string' ? value : undefined`): an account whose avatar,
|
|
46
|
+
* bio and display name were all just CLEARED serializes to
|
|
47
|
+
* `{"id":…,"publicKey":…,"username":…,"name":{},"languages":[]}` — byte-identical
|
|
48
|
+
* to the same account read as a sparse projection. There is no `null` and no
|
|
49
|
+
* `''` on the wire to key on. The information that a field was deliberately
|
|
50
|
+
* emptied exists ONLY at the call site that performed the write.
|
|
51
|
+
*
|
|
52
|
+
* So the caller declares it: `upsertCachedUser(qc, user, viewerId, { cleared:
|
|
53
|
+
* ['avatar'] })`. For a declared field an incoming EMPTY value means the field
|
|
54
|
+
* IS empty and the stale value is dropped; a MEANINGFUL incoming value still
|
|
55
|
+
* wins as usual (so `{ cleared: ['name.displayName'] }` on a personal account,
|
|
56
|
+
* where clearing the explicit name makes the server return the COMPOSED one,
|
|
57
|
+
* keeps the composed name rather than blanking it).
|
|
58
|
+
*
|
|
59
|
+
* A blanket "this source is authoritative, treat every absent field as cleared"
|
|
60
|
+
* flag was considered and rejected: because the two payloads are byte-identical,
|
|
61
|
+
* such a flag is an unverifiable promise about provenance, and the failure mode
|
|
62
|
+
* of getting it wrong is blanking real identity data in every Oxy app. Naming
|
|
63
|
+
* the fields states something the caller actually observed — which fields the
|
|
64
|
+
* user emptied — and bounds the damage to exactly those.
|
|
65
|
+
*
|
|
39
66
|
* It is a cache write only — zero network, one `setQueryData` per key.
|
|
40
67
|
*/
|
|
41
68
|
|
|
42
|
-
import type { UserNameResponse } from "@oxyhq/contracts";
|
|
69
|
+
import type { UserNameResponse, UserProfileUpdate } from "@oxyhq/contracts";
|
|
70
|
+
import type { UpdateAccountInput } from "@oxyhq/core";
|
|
43
71
|
import type { QueryClient } from "@tanstack/react-query";
|
|
44
72
|
import { useAuthStore } from "../../stores/authStore";
|
|
45
73
|
import { queryKeys } from "./queryKeys";
|
|
@@ -78,6 +106,108 @@ export interface CacheableUser {
|
|
|
78
106
|
/** The degraded display-name sentinel (ghost-author rule). */
|
|
79
107
|
const DEGRADED_DISPLAY_NAME = "Unknown user";
|
|
80
108
|
|
|
109
|
+
/**
|
|
110
|
+
* The profile fields a user can genuinely EMPTY through a real Oxy write, and
|
|
111
|
+
* for which "empty" is a state every renderer already handles.
|
|
112
|
+
*
|
|
113
|
+
* Deliberately a closed list rather than "any field": a clear DELETES data from
|
|
114
|
+
* the cache, so the blast radius of a mistaken declaration is bounded here
|
|
115
|
+
* instead of resting on each call site. `username` is absent because an account
|
|
116
|
+
* always has one; `_count` and `relationship` are absent because they are
|
|
117
|
+
* server-derived, never user-emptied — and dropping a viewer `relationship` is
|
|
118
|
+
* the exact "Follows you vanishes" bug this module exists to prevent.
|
|
119
|
+
*
|
|
120
|
+
* Each entry is clearable through a shipped write path: `avatar`, `bio`,
|
|
121
|
+
* `description` and `name.displayName` via `UserProfileUpdate` (`''` clears) and
|
|
122
|
+
* `UpdateAccountInput` (`null` clears avatar/bio; `accountCategories: []`
|
|
123
|
+
* clears the ordered category list), and `color` via its nullable field.
|
|
124
|
+
*/
|
|
125
|
+
export const CLEARABLE_USER_FIELDS = [
|
|
126
|
+
"avatar",
|
|
127
|
+
"bio",
|
|
128
|
+
"description",
|
|
129
|
+
"color",
|
|
130
|
+
"accountCategories",
|
|
131
|
+
"name.displayName",
|
|
132
|
+
] as const;
|
|
133
|
+
|
|
134
|
+
/** A field nameable in {@link UpsertCachedUserOptions.cleared}. */
|
|
135
|
+
export type ClearableUserField = (typeof CLEARABLE_USER_FIELDS)[number];
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Fields the caller deliberately emptied in a `PUT /users/me` patch. The wire
|
|
139
|
+
* response omits cleared scalars, so the cache needs this list to drop stale
|
|
140
|
+
* values immediately instead of waiting for a refetch that merges the same
|
|
141
|
+
* sparse payload.
|
|
142
|
+
*/
|
|
143
|
+
export function clearedFieldsFromProfileUpdate(
|
|
144
|
+
updates: UserProfileUpdate,
|
|
145
|
+
): ClearableUserField[] {
|
|
146
|
+
const cleared: ClearableUserField[] = [];
|
|
147
|
+
if ("avatar" in updates && !isMeaningful(updates.avatar)) {
|
|
148
|
+
cleared.push("avatar");
|
|
149
|
+
}
|
|
150
|
+
if ("bio" in updates && !isMeaningful(updates.bio)) {
|
|
151
|
+
cleared.push("bio");
|
|
152
|
+
}
|
|
153
|
+
if ("description" in updates && !isMeaningful(updates.description)) {
|
|
154
|
+
cleared.push("description");
|
|
155
|
+
}
|
|
156
|
+
if ("color" in updates && updates.color === null) {
|
|
157
|
+
cleared.push("color");
|
|
158
|
+
}
|
|
159
|
+
if (
|
|
160
|
+
updates.name !== undefined &&
|
|
161
|
+
"displayName" in updates.name &&
|
|
162
|
+
!isMeaningful(updates.name.displayName)
|
|
163
|
+
) {
|
|
164
|
+
cleared.push("name.displayName");
|
|
165
|
+
}
|
|
166
|
+
return cleared;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Same contract as {@link clearedFieldsFromProfileUpdate} for managed-account
|
|
171
|
+
* `PATCH /accounts/:id` writes (`null` clears avatar/bio/category).
|
|
172
|
+
*/
|
|
173
|
+
export function clearedFieldsFromAccountUpdate(
|
|
174
|
+
input: UpdateAccountInput,
|
|
175
|
+
): ClearableUserField[] {
|
|
176
|
+
const cleared: ClearableUserField[] = [];
|
|
177
|
+
if ("avatar" in input && !isMeaningful(input.avatar)) {
|
|
178
|
+
cleared.push("avatar");
|
|
179
|
+
}
|
|
180
|
+
if ("bio" in input && !isMeaningful(input.bio)) {
|
|
181
|
+
cleared.push("bio");
|
|
182
|
+
}
|
|
183
|
+
if (
|
|
184
|
+
"accountCategories" in input &&
|
|
185
|
+
Array.isArray(input.accountCategories) &&
|
|
186
|
+
input.accountCategories.length === 0
|
|
187
|
+
) {
|
|
188
|
+
cleared.push("accountCategories");
|
|
189
|
+
}
|
|
190
|
+
if (
|
|
191
|
+
input.name !== undefined &&
|
|
192
|
+
"displayName" in input.name &&
|
|
193
|
+
!isMeaningful(input.name.displayName)
|
|
194
|
+
) {
|
|
195
|
+
cleared.push("name.displayName");
|
|
196
|
+
}
|
|
197
|
+
return cleared;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Options for {@link upsertCachedUser}. */
|
|
201
|
+
export interface UpsertCachedUserOptions {
|
|
202
|
+
/**
|
|
203
|
+
* Fields the write that produced this user DELIBERATELY emptied. For each,
|
|
204
|
+
* an incoming empty value stops meaning "this source does not carry it" and
|
|
205
|
+
* starts meaning "it is empty" — so the stale value is dropped rather than
|
|
206
|
+
* preserved. Everything not named here keeps the anti-degradation guard.
|
|
207
|
+
*/
|
|
208
|
+
cleared?: readonly ClearableUserField[];
|
|
209
|
+
}
|
|
210
|
+
|
|
81
211
|
/** A cache entry always carries a resolved string `id`. */
|
|
82
212
|
type CachedUser = CacheableUser & { id: string; name?: UserNameResponse };
|
|
83
213
|
|
|
@@ -142,23 +272,55 @@ function normalizeIncoming(user: CacheableUser): CachedUser | null {
|
|
|
142
272
|
return cached.id ? cached : null;
|
|
143
273
|
}
|
|
144
274
|
|
|
145
|
-
/**
|
|
275
|
+
/**
|
|
276
|
+
* Copy a name WITHOUT its `displayName`. The key must end up ABSENT rather than
|
|
277
|
+
* present-and-`undefined`: consumers render `name.displayName` directly and fall
|
|
278
|
+
* back to the handle when it is missing, and a present-but-undefined key also
|
|
279
|
+
* changes what a later merge sees.
|
|
280
|
+
*/
|
|
281
|
+
function omitDisplayName(name: UserNameResponse): UserNameResponse {
|
|
282
|
+
const result: UserNameResponse = {};
|
|
283
|
+
for (const [key, value] of Object.entries(name)) {
|
|
284
|
+
if (key !== "displayName") result[key] = value;
|
|
285
|
+
}
|
|
286
|
+
return result;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Merge two `name` objects field-by-field, with anti-degradation on
|
|
291
|
+
* `displayName`.
|
|
292
|
+
*
|
|
293
|
+
* `clearDisplayName` is the declared-clear escape hatch: the incoming value
|
|
294
|
+
* still wins whenever it is meaningful (an account that clears its explicit
|
|
295
|
+
* display name gets the server-COMPOSED one back, which must not be discarded),
|
|
296
|
+
* and only a genuinely empty incoming value drops the stored one.
|
|
297
|
+
*
|
|
298
|
+
* Both incoming shapes reach the clear, and they are separate branches: the
|
|
299
|
+
* measured oxy-api response carries `name` as a PRESENT-but-empty object, while
|
|
300
|
+
* a caller passing a bare user object may carry no `name` key at all.
|
|
301
|
+
*/
|
|
146
302
|
function mergeName(
|
|
147
303
|
existing: UserNameResponse | undefined,
|
|
148
304
|
incoming: UserNameResponse | undefined,
|
|
305
|
+
clearDisplayName: boolean,
|
|
149
306
|
): UserNameResponse | undefined {
|
|
150
|
-
if (incoming === undefined)
|
|
307
|
+
if (incoming === undefined) {
|
|
308
|
+
if (!clearDisplayName || existing === undefined) return existing;
|
|
309
|
+
return omitDisplayName(existing);
|
|
310
|
+
}
|
|
151
311
|
if (existing === undefined) return incoming;
|
|
152
312
|
const merged: UserNameResponse = { ...existing };
|
|
153
313
|
for (const [key, value] of Object.entries(incoming)) {
|
|
154
314
|
if (key === "displayName") continue;
|
|
155
315
|
if (isMeaningful(value)) merged[key] = value;
|
|
156
316
|
}
|
|
157
|
-
// Never let an empty / `'Unknown user'` displayName overwrite a real one
|
|
317
|
+
// Never let an empty / `'Unknown user'` displayName overwrite a real one —
|
|
318
|
+
// unless the caller declared that the user cleared it.
|
|
158
319
|
if (isMeaningfulDisplayName(incoming.displayName)) {
|
|
159
320
|
merged.displayName = incoming.displayName;
|
|
321
|
+
return merged;
|
|
160
322
|
}
|
|
161
|
-
return merged;
|
|
323
|
+
return clearDisplayName ? omitDisplayName(merged) : merged;
|
|
162
324
|
}
|
|
163
325
|
|
|
164
326
|
/** Merge `_count` field-by-field so a partial count never replaces a fuller one. */
|
|
@@ -203,13 +365,22 @@ function mergeRelationship(
|
|
|
203
365
|
* When `includeRelationship` is false (the viewer-independent by-id key), the
|
|
204
366
|
* viewer-relative `relationship` field is never read, written, or preserved —
|
|
205
367
|
* only the by-username key carries it (`useUserByUsername`).
|
|
368
|
+
*
|
|
369
|
+
* `cleared` names the fields the write deliberately emptied. It is applied
|
|
370
|
+
* AFTER the merge, because the merge loop can only ever COPY a meaningful value
|
|
371
|
+
* — an emptied field is absent from `incoming` (see the module docs: oxy-api
|
|
372
|
+
* omits it entirely) and would otherwise survive from `existing` untouched.
|
|
206
373
|
*/
|
|
207
374
|
function mergeUsers(
|
|
208
375
|
existing: CachedUser,
|
|
209
376
|
incoming: CachedUser,
|
|
210
|
-
options?: {
|
|
377
|
+
options?: {
|
|
378
|
+
includeRelationship?: boolean;
|
|
379
|
+
cleared?: readonly ClearableUserField[];
|
|
380
|
+
},
|
|
211
381
|
): CachedUser {
|
|
212
382
|
const includeRelationship = options?.includeRelationship ?? true;
|
|
383
|
+
const cleared = options?.cleared;
|
|
213
384
|
const merged: CachedUser = { ...existing };
|
|
214
385
|
for (const [key, value] of Object.entries(incoming)) {
|
|
215
386
|
if (key === "name" || key === "_count" || key === "relationship") continue;
|
|
@@ -219,7 +390,11 @@ function mergeUsers(
|
|
|
219
390
|
}
|
|
220
391
|
if (isMeaningful(value)) merged[key] = value;
|
|
221
392
|
}
|
|
222
|
-
const name = mergeName(
|
|
393
|
+
const name = mergeName(
|
|
394
|
+
existing.name,
|
|
395
|
+
incoming.name,
|
|
396
|
+
cleared?.includes("name.displayName") ?? false,
|
|
397
|
+
);
|
|
223
398
|
if (name !== undefined) merged.name = name;
|
|
224
399
|
const count = mergeCount(existing._count, incoming._count);
|
|
225
400
|
if (count !== undefined) merged._count = count;
|
|
@@ -232,6 +407,12 @@ function mergeUsers(
|
|
|
232
407
|
} else {
|
|
233
408
|
merged.relationship = undefined;
|
|
234
409
|
}
|
|
410
|
+
if (cleared) {
|
|
411
|
+
for (const field of cleared) {
|
|
412
|
+
if (field === "name.displayName") continue; // handled by `mergeName`.
|
|
413
|
+
if (!isMeaningful(incoming[field])) delete merged[field];
|
|
414
|
+
}
|
|
415
|
+
}
|
|
235
416
|
return merged;
|
|
236
417
|
}
|
|
237
418
|
|
|
@@ -240,9 +421,15 @@ function upsertOneKey(
|
|
|
240
421
|
queryClient: QueryClient,
|
|
241
422
|
key: readonly unknown[],
|
|
242
423
|
incoming: CachedUser,
|
|
243
|
-
options: {
|
|
424
|
+
options: {
|
|
425
|
+
includeRelationship: boolean;
|
|
426
|
+
cleared?: readonly ClearableUserField[];
|
|
427
|
+
},
|
|
244
428
|
): void {
|
|
245
|
-
const mergeOpts = {
|
|
429
|
+
const mergeOpts = {
|
|
430
|
+
includeRelationship: options.includeRelationship,
|
|
431
|
+
cleared: options.cleared,
|
|
432
|
+
};
|
|
246
433
|
const existing = queryClient.getQueryData<CacheableUser>(key);
|
|
247
434
|
if (existing === undefined) {
|
|
248
435
|
// Cold slot: seed the full incoming object, STALE, so react-query refetches
|
|
@@ -284,20 +471,27 @@ function resolveViewerId(viewerId?: string): string {
|
|
|
284
471
|
* @param user A `User`-shaped object (may be sparse).
|
|
285
472
|
* @param viewerId The active viewer id for the by-username key. Defaults to
|
|
286
473
|
* the current auth-store user id.
|
|
474
|
+
* @param options `cleared` names the fields the write deliberately emptied
|
|
475
|
+
* — the ONLY way "remove my picture" can propagate, since a
|
|
476
|
+
* cleared field and an uncarried one are byte-identical on
|
|
477
|
+
* the wire (see the module docs).
|
|
287
478
|
*/
|
|
288
479
|
export function upsertCachedUser(
|
|
289
480
|
queryClient: QueryClient,
|
|
290
481
|
user: CacheableUser,
|
|
291
482
|
viewerId?: string,
|
|
483
|
+
options?: UpsertCachedUserOptions,
|
|
292
484
|
): void {
|
|
293
485
|
const incoming = normalizeIncoming(user);
|
|
294
486
|
if (!incoming) return;
|
|
487
|
+
const cleared = options?.cleared;
|
|
295
488
|
|
|
296
489
|
// By-id identity entry (read by `useUserById`). Not viewer-scoped — never store
|
|
297
490
|
// the viewer-relative `relationship` here or one viewer's follow state leaks
|
|
298
491
|
// into every other viewer's by-id cache entry.
|
|
299
492
|
upsertOneKey(queryClient, queryKeys.users.detail(incoming.id), incoming, {
|
|
300
493
|
includeRelationship: false,
|
|
494
|
+
cleared,
|
|
301
495
|
});
|
|
302
496
|
|
|
303
497
|
const username = incoming.username;
|
|
@@ -307,7 +501,10 @@ export function upsertCachedUser(
|
|
|
307
501
|
// the key through the SAME helper the hook uses so username normalization
|
|
308
502
|
// (`trim().toLowerCase()`) matches byte-for-byte.
|
|
309
503
|
const key = queryKeys.users.byUsername(username, resolveViewerId(viewerId));
|
|
310
|
-
upsertOneKey(queryClient, key, incoming, {
|
|
504
|
+
upsertOneKey(queryClient, key, incoming, {
|
|
505
|
+
includeRelationship: true,
|
|
506
|
+
cleared,
|
|
507
|
+
});
|
|
311
508
|
}
|
|
312
509
|
}
|
|
313
510
|
|
|
@@ -315,6 +512,10 @@ export function upsertCachedUser(
|
|
|
315
512
|
* Batch merge-upsert many users at once (for a feed / list / search response).
|
|
316
513
|
* Resolves the viewer id once and upserts each user cumulatively — a user that
|
|
317
514
|
* appears twice merges both slices into the single cache entry.
|
|
515
|
+
*
|
|
516
|
+
* Takes NO `cleared`, deliberately: a batch is a multi-user projection, so it is
|
|
517
|
+
* exactly the sparse source the anti-degradation guard exists for, and one
|
|
518
|
+
* declaration could not be true of every user in the array anyway.
|
|
318
519
|
*/
|
|
319
520
|
export function upsertCachedUsers(
|
|
320
521
|
queryClient: QueryClient,
|
|
@@ -18,6 +18,10 @@ import { SettingsIcon } from '../components/SettingsIcon';
|
|
|
18
18
|
import { useOxy } from '../context/OxyContext';
|
|
19
19
|
import { useI18n } from '../hooks/useI18n';
|
|
20
20
|
import { useSurfaceHeader } from '../hooks/useSurfaceHeader';
|
|
21
|
+
import {
|
|
22
|
+
clearedFieldsFromAccountUpdate,
|
|
23
|
+
upsertCachedUser,
|
|
24
|
+
} from '../hooks/queries/userCache';
|
|
21
25
|
|
|
22
26
|
const DISPLAY_NAME_MAX = MAX_DISPLAY_NAME_LENGTH;
|
|
23
27
|
const BIO_MAX = 160;
|
|
@@ -78,7 +82,11 @@ const AccountSettingsScreen: React.FC<BaseScreenProps> = ({ onClose, goBack, nav
|
|
|
78
82
|
const updateMutation = useMutation({
|
|
79
83
|
mutationKey: ['accounts', 'update', id],
|
|
80
84
|
mutationFn: (input: UpdateAccountInput) => oxyServices.updateAccount(id, input),
|
|
81
|
-
onSuccess: () => {
|
|
85
|
+
onSuccess: (updatedNode, input) => {
|
|
86
|
+
const cleared = clearedFieldsFromAccountUpdate(input);
|
|
87
|
+
upsertCachedUser(queryClient, updatedNode.account, user?.id, {
|
|
88
|
+
cleared: cleared.length > 0 ? cleared : undefined,
|
|
89
|
+
});
|
|
82
90
|
queryClient.invalidateQueries({ queryKey: ['accounts', 'detail', id] });
|
|
83
91
|
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
|
84
92
|
toast.success(t('accounts.settings.toasts.saved') || 'Account updated');
|