@oxyhq/core 3.11.0 → 3.13.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.
@@ -30,6 +30,7 @@ import { OxyServicesTopicsMixin } from './OxyServices.topics';
30
30
  import { OxyServicesManagedAccountsMixin } from './OxyServices.managedAccounts';
31
31
  import { OxyServicesContactsMixin } from './OxyServices.contacts';
32
32
  import { OxyServicesAppDataMixin } from './OxyServices.appData';
33
+ import { OxyServicesCivicMixin } from './OxyServices.civic';
33
34
 
34
35
  /**
35
36
  * Instance shape of every mixin in the pipeline, intersected. The runtime
@@ -64,6 +65,7 @@ type AllMixinInstances =
64
65
  & InstanceType<ReturnType<typeof OxyServicesManagedAccountsMixin<typeof OxyServicesBase>>>
65
66
  & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>>
66
67
  & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>>
68
+ & InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>>
67
69
  & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
68
70
 
69
71
  /**
@@ -130,6 +132,8 @@ const MIXIN_PIPELINE: MixinFunction[] = [
130
132
  OxyServicesManagedAccountsMixin,
131
133
  OxyServicesContactsMixin,
132
134
  OxyServicesAppDataMixin,
135
+ // Civic / Commons "Oxy ID" (public signed cards, Oxy ID QR payload)
136
+ OxyServicesCivicMixin,
133
137
 
134
138
  // Utility (last, can use all above)
135
139
  OxyServicesUtilityMixin,
@@ -0,0 +1,180 @@
1
+ import { normalizeProfileLinks, type ProfileLink } from '../profileLinks';
2
+
3
+ describe('normalizeProfileLinks', () => {
4
+ describe('linksMetadata path (preferred)', () => {
5
+ it('maps title + url and uses the entry id when present', () => {
6
+ const result = normalizeProfileLinks([
7
+ { url: 'https://oxy.so', title: 'Oxy', id: 'abc' },
8
+ ]);
9
+ expect(result).toEqual<ProfileLink[]>([
10
+ { id: 'abc', title: 'Oxy', url: 'https://oxy.so' },
11
+ ]);
12
+ });
13
+
14
+ it('falls back to the index when an entry has no id', () => {
15
+ const result = normalizeProfileLinks([
16
+ { url: 'https://a.example', title: 'A' },
17
+ { url: 'https://b.example', title: 'B' },
18
+ ]);
19
+ expect(result).toEqual<ProfileLink[]>([
20
+ { id: '0', title: 'A', url: 'https://a.example' },
21
+ { id: '1', title: 'B', url: 'https://b.example' },
22
+ ]);
23
+ });
24
+
25
+ it('omits title when absent', () => {
26
+ const result = normalizeProfileLinks([{ url: 'https://no-title.example' }]);
27
+ expect(result).toEqual<ProfileLink[]>([
28
+ { id: '0', url: 'https://no-title.example' },
29
+ ]);
30
+ expect(result[0]).not.toHaveProperty('title');
31
+ });
32
+
33
+ it('carries through description and image when present', () => {
34
+ const result = normalizeProfileLinks([
35
+ {
36
+ url: 'https://oxy.so',
37
+ title: 'Oxy',
38
+ description: 'The Oxy ecosystem',
39
+ image: 'https://cdn.example/oxy.png',
40
+ id: 'abc',
41
+ },
42
+ ]);
43
+ expect(result).toEqual<ProfileLink[]>([
44
+ {
45
+ id: 'abc',
46
+ title: 'Oxy',
47
+ url: 'https://oxy.so',
48
+ description: 'The Oxy ecosystem',
49
+ image: 'https://cdn.example/oxy.png',
50
+ },
51
+ ]);
52
+ });
53
+
54
+ it('omits description and image when absent', () => {
55
+ const result = normalizeProfileLinks([
56
+ { url: 'https://min.example', title: 'Min' },
57
+ ]);
58
+ expect(result).toEqual<ProfileLink[]>([
59
+ { id: '0', title: 'Min', url: 'https://min.example' },
60
+ ]);
61
+ expect(result[0]).not.toHaveProperty('description');
62
+ expect(result[0]).not.toHaveProperty('image');
63
+ });
64
+
65
+ it('carries description and image independently of title', () => {
66
+ const result = normalizeProfileLinks([
67
+ { url: 'https://only-image.example', image: 'https://cdn.example/i.png' },
68
+ { url: 'https://only-desc.example', description: 'Just a description' },
69
+ ]);
70
+ expect(result).toEqual<ProfileLink[]>([
71
+ { id: '0', url: 'https://only-image.example', image: 'https://cdn.example/i.png' },
72
+ { id: '1', url: 'https://only-desc.example', description: 'Just a description' },
73
+ ]);
74
+ expect(result[0]).not.toHaveProperty('title');
75
+ expect(result[0]).not.toHaveProperty('description');
76
+ expect(result[1]).not.toHaveProperty('title');
77
+ expect(result[1]).not.toHaveProperty('image');
78
+ });
79
+
80
+ it('drops entries with missing or empty url and keeps stable index ids', () => {
81
+ const result = normalizeProfileLinks([
82
+ { url: 'https://keep.example', title: 'Keep', id: 'keep' },
83
+ { url: '', title: 'Empty' },
84
+ { url: ' ', title: 'Whitespace' },
85
+ { url: 'https://second.example', title: 'Second' },
86
+ ]);
87
+ expect(result).toEqual<ProfileLink[]>([
88
+ { id: 'keep', title: 'Keep', url: 'https://keep.example' },
89
+ // index is preserved from the source array position (3), not re-numbered
90
+ { id: '3', title: 'Second', url: 'https://second.example' },
91
+ ]);
92
+ });
93
+
94
+ it('trims surrounding whitespace from urls', () => {
95
+ const result = normalizeProfileLinks([{ url: ' https://trim.example ' }]);
96
+ expect(result).toEqual<ProfileLink[]>([
97
+ { id: '0', url: 'https://trim.example' },
98
+ ]);
99
+ });
100
+
101
+ it('does NOT add a scheme to bare urls', () => {
102
+ const result = normalizeProfileLinks([{ url: 'oxy.so', title: 'Bare' }]);
103
+ expect(result).toEqual<ProfileLink[]>([
104
+ { id: '0', title: 'Bare', url: 'oxy.so' },
105
+ ]);
106
+ });
107
+ });
108
+
109
+ describe('legacy links path (no linksMetadata)', () => {
110
+ it('maps strings to { id, url } without title/description/image', () => {
111
+ const result = normalizeProfileLinks(undefined, [
112
+ 'https://a.example',
113
+ 'https://b.example',
114
+ ]);
115
+ expect(result).toEqual<ProfileLink[]>([
116
+ { id: '0', url: 'https://a.example' },
117
+ { id: '1', url: 'https://b.example' },
118
+ ]);
119
+ expect(
120
+ result.every(
121
+ (link) =>
122
+ !('title' in link) &&
123
+ !('description' in link) &&
124
+ !('image' in link),
125
+ ),
126
+ ).toBe(true);
127
+ });
128
+
129
+ it('drops empty and whitespace-only strings, preserving source index ids', () => {
130
+ const result = normalizeProfileLinks(undefined, [
131
+ 'https://keep.example',
132
+ '',
133
+ ' ',
134
+ 'https://second.example',
135
+ ]);
136
+ expect(result).toEqual<ProfileLink[]>([
137
+ { id: '0', url: 'https://keep.example' },
138
+ { id: '3', url: 'https://second.example' },
139
+ ]);
140
+ });
141
+ });
142
+
143
+ describe('fall-through and empty inputs', () => {
144
+ it('returns [] when both inputs are absent', () => {
145
+ expect(normalizeProfileLinks()).toEqual([]);
146
+ expect(normalizeProfileLinks(undefined, undefined)).toEqual([]);
147
+ });
148
+
149
+ it('returns [] when both inputs are empty arrays', () => {
150
+ expect(normalizeProfileLinks([], [])).toEqual([]);
151
+ });
152
+
153
+ it('falls through to legacy links when linksMetadata is empty', () => {
154
+ const result = normalizeProfileLinks([], ['https://legacy.example']);
155
+ expect(result).toEqual<ProfileLink[]>([
156
+ { id: '0', url: 'https://legacy.example' },
157
+ ]);
158
+ });
159
+
160
+ it('returns [] (does NOT fall back to links) when linksMetadata is non-empty but every entry is dropped', () => {
161
+ const result = normalizeProfileLinks(
162
+ [{ url: '' }, { url: ' ' }],
163
+ ['https://legacy.example'],
164
+ );
165
+ expect(result).toEqual([]);
166
+ });
167
+ });
168
+
169
+ it('is pure — does not mutate its inputs', () => {
170
+ const linksMetadata = [{ url: 'https://oxy.so', title: 'Oxy', id: 'abc' }];
171
+ const links = ['https://legacy.example'];
172
+ const metadataSnapshot = JSON.stringify(linksMetadata);
173
+ const linksSnapshot = JSON.stringify(links);
174
+
175
+ normalizeProfileLinks(linksMetadata, links);
176
+
177
+ expect(JSON.stringify(linksMetadata)).toBe(metadataSnapshot);
178
+ expect(JSON.stringify(links)).toBe(linksSnapshot);
179
+ });
180
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Normalized profile link shape for display.
3
+ *
4
+ * `id` is a stable key for list rendering (the source entry's id when present,
5
+ * otherwise the source index as a string). `url` is always a non-empty string.
6
+ * `title`, `description`, and `image` are carried through from `linksMetadata`
7
+ * only when present; the legacy `links: string[]` path produces just
8
+ * `{ id, url }`.
9
+ */
10
+ export interface ProfileLink {
11
+ id: string;
12
+ title?: string;
13
+ url: string;
14
+ description?: string;
15
+ image?: string;
16
+ }
17
+
18
+ /** Source shape of a single `User.linksMetadata` entry. */
19
+ export interface ProfileLinkMetadata {
20
+ url: string;
21
+ title?: string;
22
+ description?: string;
23
+ image?: string;
24
+ id?: string;
25
+ }
26
+
27
+ function cleanUrl(value: unknown): string | null {
28
+ if (typeof value !== 'string') return null;
29
+ const trimmed = value.trim();
30
+ return trimmed.length > 0 ? trimmed : null;
31
+ }
32
+
33
+ /**
34
+ * Normalizes a user's profile links into a clean display shape.
35
+ *
36
+ * Pure, no side effects, no I/O.
37
+ *
38
+ * - Prefers `linksMetadata` when it is a non-empty array: maps each entry to
39
+ * `{ id, title, url, description, image }`, using `entry.id` when present and
40
+ * falling back to the entry index. `title`, `description`, and `image` are
41
+ * carried through only when they are present strings. Entries without a
42
+ * non-empty string `url` are dropped.
43
+ * - Otherwise falls back to the legacy `links` string array: maps each string to
44
+ * `{ id: <index>, url }` (no title/description/image). Empty/non-string values
45
+ * are dropped.
46
+ * - Returns `[]` when both are absent or empty (including when `linksMetadata`
47
+ * is present but every entry is dropped — it does NOT fall back to `links`).
48
+ *
49
+ * URLs are trimmed and blanks are filtered out. This does NOT add a scheme such
50
+ * as `https://`; prefixing is a display concern left to the caller.
51
+ */
52
+ export function normalizeProfileLinks(
53
+ linksMetadata?: ProfileLinkMetadata[],
54
+ links?: string[],
55
+ ): ProfileLink[] {
56
+ if (Array.isArray(linksMetadata) && linksMetadata.length > 0) {
57
+ const result: ProfileLink[] = [];
58
+ linksMetadata.forEach((entry, index) => {
59
+ const url = cleanUrl(entry?.url);
60
+ if (!url) return;
61
+ const id =
62
+ typeof entry?.id === 'string' && entry.id.trim().length > 0
63
+ ? entry.id
64
+ : String(index);
65
+ const title = typeof entry?.title === 'string' ? entry.title : undefined;
66
+ const description =
67
+ typeof entry?.description === 'string' ? entry.description : undefined;
68
+ const image = typeof entry?.image === 'string' ? entry.image : undefined;
69
+ result.push({
70
+ id,
71
+ url,
72
+ ...(title !== undefined ? { title } : {}),
73
+ ...(description !== undefined ? { description } : {}),
74
+ ...(image !== undefined ? { image } : {}),
75
+ });
76
+ });
77
+ return result;
78
+ }
79
+
80
+ if (Array.isArray(links) && links.length > 0) {
81
+ const result: ProfileLink[] = [];
82
+ links.forEach((value, index) => {
83
+ const url = cleanUrl(value);
84
+ if (!url) return;
85
+ result.push({ id: String(index), url });
86
+ });
87
+ return result;
88
+ }
89
+
90
+ return [];
91
+ }