@ai-matrx/messaging 0.11.8 → 0.12.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/CHANGELOG.md +59 -0
- package/README.md +10 -3
- package/dist/index.cjs +187 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +154 -42
- package/dist/index.d.ts +154 -42
- package/dist/index.js +187 -22
- package/dist/index.js.map +1 -1
- package/dist/react.cjs +246 -31
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +174 -50
- package/dist/react.d.ts +174 -50
- package/dist/react.js +246 -31
- package/dist/react.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1591,6 +1591,145 @@ function formatLastSeen(lastSeenMs, now = Date.now()) {
|
|
|
1591
1591
|
return `Active ${formatRelativeTime(lastSeenMs, { style: "short", now })}`;
|
|
1592
1592
|
}
|
|
1593
1593
|
|
|
1594
|
+
// src/core/session-identity.ts
|
|
1595
|
+
var RESOLVING = Object.freeze({
|
|
1596
|
+
state: "resolving",
|
|
1597
|
+
userId: null
|
|
1598
|
+
});
|
|
1599
|
+
var SERVER_SESSION_IDENTITY = RESOLVING;
|
|
1600
|
+
function authOf(client) {
|
|
1601
|
+
const candidate = client.auth;
|
|
1602
|
+
if (candidate === null || typeof candidate !== "object") return null;
|
|
1603
|
+
const auth = candidate;
|
|
1604
|
+
if (typeof auth.getSession !== "function") return null;
|
|
1605
|
+
if (typeof auth.onAuthStateChange !== "function") return null;
|
|
1606
|
+
return auth;
|
|
1607
|
+
}
|
|
1608
|
+
function userIdOf(session) {
|
|
1609
|
+
const id = session?.user?.id;
|
|
1610
|
+
return typeof id === "string" && id.length > 0 ? asUserId(id) : null;
|
|
1611
|
+
}
|
|
1612
|
+
var NO_AUTH_SOURCE = {
|
|
1613
|
+
message: "Messaging cannot tell who is signed in: the Supabase client it was given has no auth session source.",
|
|
1614
|
+
remedy: "Pass your app's browser Supabase client \u2014 the same client it signs in with \u2014 to <MessagingProvider client={...}> or createMessagingRepository({ client }). Messaging reads the acting user from that client's session; there is no prop or option for a user id, on purpose (DD-241)."
|
|
1615
|
+
};
|
|
1616
|
+
function createSessionIdentity(options) {
|
|
1617
|
+
const { client, onDiagnostic } = options;
|
|
1618
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1619
|
+
let current = RESOLVING;
|
|
1620
|
+
let disposed = false;
|
|
1621
|
+
const publish = (state, userId) => {
|
|
1622
|
+
if (disposed) return;
|
|
1623
|
+
if (current.state === state && current.userId === userId) return;
|
|
1624
|
+
current = Object.freeze({ state, userId });
|
|
1625
|
+
for (const listener of [...listeners]) listener();
|
|
1626
|
+
};
|
|
1627
|
+
const subscribeOnly = (hasAuthSource) => ({
|
|
1628
|
+
hasAuthSource,
|
|
1629
|
+
snapshot: () => current,
|
|
1630
|
+
subscribe: (listener) => {
|
|
1631
|
+
listeners.add(listener);
|
|
1632
|
+
return () => {
|
|
1633
|
+
listeners.delete(listener);
|
|
1634
|
+
};
|
|
1635
|
+
},
|
|
1636
|
+
dispose: () => {
|
|
1637
|
+
disposed = true;
|
|
1638
|
+
listeners.clear();
|
|
1639
|
+
}
|
|
1640
|
+
});
|
|
1641
|
+
const auth = authOf(client);
|
|
1642
|
+
if (auth === null) {
|
|
1643
|
+
onDiagnostic?.({ level: "error", ...NO_AUTH_SOURCE });
|
|
1644
|
+
current = Object.freeze({ state: "resolved", userId: null });
|
|
1645
|
+
return subscribeOnly(false);
|
|
1646
|
+
}
|
|
1647
|
+
void (async () => {
|
|
1648
|
+
try {
|
|
1649
|
+
const result = await auth.getSession();
|
|
1650
|
+
publish("resolved", userIdOf(result?.data?.session));
|
|
1651
|
+
} catch (error) {
|
|
1652
|
+
onDiagnostic?.({
|
|
1653
|
+
level: "warn",
|
|
1654
|
+
message: `Messaging could not read the signed-in session: ${String(
|
|
1655
|
+
error?.message ?? error
|
|
1656
|
+
)}`,
|
|
1657
|
+
remedy: "Messages stay unavailable on this device until the session can be read. Reload the page; if it persists, sign in again."
|
|
1658
|
+
});
|
|
1659
|
+
publish("resolved", null);
|
|
1660
|
+
}
|
|
1661
|
+
})();
|
|
1662
|
+
const subscription = auth.onAuthStateChange((_event, session) => {
|
|
1663
|
+
publish("resolved", userIdOf(session));
|
|
1664
|
+
});
|
|
1665
|
+
const base = subscribeOnly(true);
|
|
1666
|
+
return {
|
|
1667
|
+
hasAuthSource: true,
|
|
1668
|
+
snapshot: base.snapshot,
|
|
1669
|
+
subscribe: base.subscribe,
|
|
1670
|
+
dispose: () => {
|
|
1671
|
+
base.dispose();
|
|
1672
|
+
try {
|
|
1673
|
+
subscription.data.subscription.unsubscribe();
|
|
1674
|
+
} catch {
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
function actingUserIdOf(identity) {
|
|
1680
|
+
return new Promise((resolve, reject) => {
|
|
1681
|
+
const settle = () => {
|
|
1682
|
+
const snapshot = identity.snapshot();
|
|
1683
|
+
if (snapshot.state !== "resolved") return false;
|
|
1684
|
+
if (snapshot.userId === null) {
|
|
1685
|
+
reject(identity.hasAuthSource ? noSignedInUserError() : noAuthSourceError());
|
|
1686
|
+
return true;
|
|
1687
|
+
}
|
|
1688
|
+
resolve(snapshot.userId);
|
|
1689
|
+
return true;
|
|
1690
|
+
};
|
|
1691
|
+
if (settle()) return;
|
|
1692
|
+
const unsubscribe = identity.subscribe(() => {
|
|
1693
|
+
if (settle()) unsubscribe();
|
|
1694
|
+
});
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
function noSignedInUserError() {
|
|
1698
|
+
return new MessagingError(
|
|
1699
|
+
"session-unavailable",
|
|
1700
|
+
"Messaging has no signed-in user: the Supabase client's session names nobody.",
|
|
1701
|
+
"Sign in, then try again. Messaging takes the acting user from the session of the client it was given \u2014 never from an id handed in by the caller (DD-241), because a handed-in id goes stale the moment the auth cookie rotates and lands on the wire as sender_id and created_by."
|
|
1702
|
+
);
|
|
1703
|
+
}
|
|
1704
|
+
function noAuthSourceError() {
|
|
1705
|
+
return new MessagingError(
|
|
1706
|
+
"misconfigured",
|
|
1707
|
+
NO_AUTH_SOURCE.message,
|
|
1708
|
+
NO_AUTH_SOURCE.remedy
|
|
1709
|
+
);
|
|
1710
|
+
}
|
|
1711
|
+
async function readActingUserId(client) {
|
|
1712
|
+
const auth = authOf(client);
|
|
1713
|
+
if (auth === null) throw noAuthSourceError();
|
|
1714
|
+
let session;
|
|
1715
|
+
try {
|
|
1716
|
+
const result = await auth.getSession();
|
|
1717
|
+
session = result?.data?.session;
|
|
1718
|
+
} catch (error) {
|
|
1719
|
+
throw new MessagingError(
|
|
1720
|
+
"session-unavailable",
|
|
1721
|
+
`Messaging could not read the signed-in session: ${String(
|
|
1722
|
+
error?.message ?? error
|
|
1723
|
+
)}`,
|
|
1724
|
+
"Reload the page and try again; if it persists, sign in again.",
|
|
1725
|
+
error
|
|
1726
|
+
);
|
|
1727
|
+
}
|
|
1728
|
+
const userId = userIdOf(session);
|
|
1729
|
+
if (userId === null) throw noSignedInUserError();
|
|
1730
|
+
return userId;
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1594
1733
|
// src/core/repository.ts
|
|
1595
1734
|
var MESSAGING_SCHEMA = "communication";
|
|
1596
1735
|
var MESSAGING_RPC_SCHEMA = "public";
|
|
@@ -1627,8 +1766,15 @@ function sleep(ms) {
|
|
|
1627
1766
|
return new Promise((resolve) => setTimeout(resolve, jittered));
|
|
1628
1767
|
}
|
|
1629
1768
|
function createMessagingRepository(options) {
|
|
1630
|
-
const
|
|
1631
|
-
const
|
|
1769
|
+
const org = requireOrg(options.organizationId, "createMessagingRepository");
|
|
1770
|
+
const handedIn = options.identity;
|
|
1771
|
+
if (handedIn !== void 0 && handedIn !== null && handedIn.userId !== void 0) {
|
|
1772
|
+
options.onDiagnostic?.(
|
|
1773
|
+
"createMessagingRepository({ identity: { userId } }) is ignored: since 0.12.0 messaging reads the acting user from the session of the client it was given (DD-241). Remove the option and pass { client, organizationId } \u2014 a handed-in id goes stale the moment the auth cookie rotates, and it lands on the wire as sender_id and created_by."
|
|
1774
|
+
);
|
|
1775
|
+
}
|
|
1776
|
+
const sessionIdentity = options.sessionIdentity;
|
|
1777
|
+
const actingUserId = () => sessionIdentity !== void 0 ? actingUserIdOf(sessionIdentity) : readActingUserId(options.client);
|
|
1632
1778
|
const userCache = createReadCache({
|
|
1633
1779
|
ttlMs: options.userTtlMs ?? 5 * 6e4,
|
|
1634
1780
|
...options.now !== void 0 ? { now: options.now } : {}
|
|
@@ -1665,6 +1811,9 @@ function createMessagingRepository(options) {
|
|
|
1665
1811
|
}
|
|
1666
1812
|
}
|
|
1667
1813
|
}
|
|
1814
|
+
function actingUser(operation) {
|
|
1815
|
+
return withSessionRetry(operation, () => actingUserId());
|
|
1816
|
+
}
|
|
1668
1817
|
async function rpc(fn, args, operation) {
|
|
1669
1818
|
const { data, error } = await rpcDb().rpc(fn, args);
|
|
1670
1819
|
if (error !== null) throw normalizeMessagingError(error, operation);
|
|
@@ -1688,17 +1837,19 @@ function createMessagingRepository(options) {
|
|
|
1688
1837
|
return found;
|
|
1689
1838
|
}
|
|
1690
1839
|
const repository = {
|
|
1691
|
-
|
|
1840
|
+
organizationId: org,
|
|
1841
|
+
actingUserId,
|
|
1692
1842
|
async listConversations(args = {}) {
|
|
1693
1843
|
const limit = args.limit ?? 30;
|
|
1694
1844
|
const archived = args.archived ?? DEFAULT_MESSAGING_ARCHIVE_FILTER;
|
|
1695
1845
|
const operation = "listConversations";
|
|
1846
|
+
const me = await actingUser(operation);
|
|
1696
1847
|
const rows = await withSessionRetry(
|
|
1697
1848
|
operation,
|
|
1698
1849
|
() => rpc(
|
|
1699
1850
|
RPCS.conversationsWithDetails,
|
|
1700
1851
|
{
|
|
1701
|
-
p_user_id:
|
|
1852
|
+
p_user_id: me,
|
|
1702
1853
|
p_limit: limit + 1,
|
|
1703
1854
|
p_before_sort_at: args.cursor?.beforeSortAt ?? null,
|
|
1704
1855
|
p_before_conversation_id: args.cursor?.beforeConversationId ?? null,
|
|
@@ -1715,7 +1866,7 @@ function createMessagingRepository(options) {
|
|
|
1715
1866
|
throw invalidResponse(operation, `${RPCS.conversationsWithDetails} did not return rows`);
|
|
1716
1867
|
}
|
|
1717
1868
|
const projected = (rows ?? []).map(
|
|
1718
|
-
(row) => projectConversationSummary(row,
|
|
1869
|
+
(row) => projectConversationSummary(row, me, org)
|
|
1719
1870
|
);
|
|
1720
1871
|
const hasMore = projected.length > limit;
|
|
1721
1872
|
const items = hasMore ? projected.slice(0, limit) : projected;
|
|
@@ -1729,12 +1880,13 @@ function createMessagingRepository(options) {
|
|
|
1729
1880
|
async countArchivedConversations(args = {}) {
|
|
1730
1881
|
const limit = args.limit ?? 100;
|
|
1731
1882
|
const operation = "countArchivedConversations";
|
|
1883
|
+
const me = await actingUser(operation);
|
|
1732
1884
|
const rows = await withSessionRetry(
|
|
1733
1885
|
operation,
|
|
1734
1886
|
() => rpc(
|
|
1735
1887
|
RPCS.conversationsWithDetails,
|
|
1736
1888
|
{
|
|
1737
|
-
p_user_id:
|
|
1889
|
+
p_user_id: me,
|
|
1738
1890
|
p_limit: limit + 1,
|
|
1739
1891
|
p_before_sort_at: null,
|
|
1740
1892
|
p_before_conversation_id: null,
|
|
@@ -1751,6 +1903,7 @@ function createMessagingRepository(options) {
|
|
|
1751
1903
|
},
|
|
1752
1904
|
async getConversation(id) {
|
|
1753
1905
|
const operation = "getConversation";
|
|
1906
|
+
const me = await actingUser(operation);
|
|
1754
1907
|
const { data, error } = await withSessionRetry(
|
|
1755
1908
|
operation,
|
|
1756
1909
|
async () => db().from(TABLES.conversations).select(CONVERSATION_COLUMNS).eq("id", id).single()
|
|
@@ -1771,7 +1924,7 @@ function createMessagingRepository(options) {
|
|
|
1771
1924
|
participants: [],
|
|
1772
1925
|
unread_count: 0
|
|
1773
1926
|
},
|
|
1774
|
-
|
|
1927
|
+
me,
|
|
1775
1928
|
org
|
|
1776
1929
|
);
|
|
1777
1930
|
return summary.conversation;
|
|
@@ -1815,6 +1968,7 @@ function createMessagingRepository(options) {
|
|
|
1815
1968
|
},
|
|
1816
1969
|
async getOrCreateDirectConversation(otherUserId) {
|
|
1817
1970
|
const operation = "getOrCreateDirectConversation";
|
|
1971
|
+
const me = await actingUser(operation);
|
|
1818
1972
|
const id = await withSessionRetry(
|
|
1819
1973
|
operation,
|
|
1820
1974
|
() => rpc(
|
|
@@ -1823,7 +1977,7 @@ function createMessagingRepository(options) {
|
|
|
1823
1977
|
// The RPC's own guard requires an `authenticated` caller to pass
|
|
1824
1978
|
// THEMSELVES as user1; passing them in the other slot is a denial,
|
|
1825
1979
|
// not a preference.
|
|
1826
|
-
p_user1_id:
|
|
1980
|
+
p_user1_id: me,
|
|
1827
1981
|
p_user2_id: otherUserId,
|
|
1828
1982
|
p_organization_id: org
|
|
1829
1983
|
},
|
|
@@ -1837,13 +1991,14 @@ function createMessagingRepository(options) {
|
|
|
1837
1991
|
},
|
|
1838
1992
|
async createGroupConversation({ name, memberIds }) {
|
|
1839
1993
|
const operation = "createGroupConversation";
|
|
1994
|
+
const me = await actingUser(operation);
|
|
1840
1995
|
const { data, error } = await withSessionRetry(
|
|
1841
1996
|
operation,
|
|
1842
1997
|
async () => db().from(TABLES.conversations).insert({
|
|
1843
1998
|
type: "group",
|
|
1844
1999
|
group_name: name,
|
|
1845
2000
|
organization_id: org,
|
|
1846
|
-
created_by:
|
|
2001
|
+
created_by: me
|
|
1847
2002
|
}).select("id").single()
|
|
1848
2003
|
);
|
|
1849
2004
|
if (error !== null) throw normalizeMessagingError(error, operation);
|
|
@@ -1851,14 +2006,14 @@ function createMessagingRepository(options) {
|
|
|
1851
2006
|
if (typeof conversationId !== "string") {
|
|
1852
2007
|
throw invalidResponse(operation, "insert returned no conversation id");
|
|
1853
2008
|
}
|
|
1854
|
-
const members = [.../* @__PURE__ */ new Set([
|
|
2009
|
+
const members = [.../* @__PURE__ */ new Set([me, ...memberIds])];
|
|
1855
2010
|
const { error: memberError } = await db().from(TABLES.participants).insert(
|
|
1856
2011
|
members.map((userId) => ({
|
|
1857
2012
|
conversation_id: conversationId,
|
|
1858
2013
|
user_id: userId,
|
|
1859
|
-
role: userId ===
|
|
2014
|
+
role: userId === me ? "owner" : "member",
|
|
1860
2015
|
organization_id: org,
|
|
1861
|
-
created_by:
|
|
2016
|
+
created_by: me
|
|
1862
2017
|
}))
|
|
1863
2018
|
);
|
|
1864
2019
|
if (memberError !== null) throw normalizeMessagingError(memberError, operation);
|
|
@@ -1866,11 +2021,12 @@ function createMessagingRepository(options) {
|
|
|
1866
2021
|
},
|
|
1867
2022
|
async insertMessage(draft, clientMessageId) {
|
|
1868
2023
|
const operation = "insertMessage";
|
|
2024
|
+
const me = await actingUser(operation);
|
|
1869
2025
|
const { data, error } = await db().from(TABLES.messages).insert({
|
|
1870
2026
|
conversation_id: draft.conversationId,
|
|
1871
|
-
sender_id:
|
|
2027
|
+
sender_id: me,
|
|
1872
2028
|
organization_id: org,
|
|
1873
|
-
created_by:
|
|
2029
|
+
created_by: me,
|
|
1874
2030
|
content: draft.content,
|
|
1875
2031
|
message_type: draft.kind ?? "text",
|
|
1876
2032
|
status: "sent",
|
|
@@ -1892,10 +2048,11 @@ function createMessagingRepository(options) {
|
|
|
1892
2048
|
},
|
|
1893
2049
|
async editMessage(id, content) {
|
|
1894
2050
|
const operation = "editMessage";
|
|
2051
|
+
const me = await actingUser(operation);
|
|
1895
2052
|
const { data, error } = await db().from(TABLES.messages).update({
|
|
1896
2053
|
content,
|
|
1897
2054
|
edited_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1898
|
-
updated_by:
|
|
2055
|
+
updated_by: me
|
|
1899
2056
|
}).eq("id", id).select(MESSAGE_COLUMNS).single();
|
|
1900
2057
|
if (error !== null) throw normalizeMessagingError(error, operation);
|
|
1901
2058
|
if (data === null) throw invalidResponse(operation, "update returned no row");
|
|
@@ -1903,47 +2060,53 @@ function createMessagingRepository(options) {
|
|
|
1903
2060
|
},
|
|
1904
2061
|
async deleteMessage(id, forEveryone) {
|
|
1905
2062
|
const operation = "deleteMessage";
|
|
2063
|
+
const me = await actingUser(operation);
|
|
1906
2064
|
const { error } = await db().from(TABLES.messages).update({
|
|
1907
2065
|
deleted_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1908
2066
|
deleted_for_everyone: forEveryone,
|
|
1909
|
-
updated_by:
|
|
2067
|
+
updated_by: me
|
|
1910
2068
|
}).eq("id", id);
|
|
1911
2069
|
if (error !== null) throw normalizeMessagingError(error, operation);
|
|
1912
2070
|
},
|
|
1913
2071
|
async markRead(conversationId, at) {
|
|
1914
2072
|
const operation = "markRead";
|
|
1915
|
-
const
|
|
2073
|
+
const me = await actingUser(operation);
|
|
2074
|
+
const { error } = await db().from(TABLES.participants).update({ last_read_at: at ?? (/* @__PURE__ */ new Date()).toISOString(), updated_by: me }).eq("conversation_id", conversationId).eq("user_id", me);
|
|
1916
2075
|
if (error !== null) throw normalizeMessagingError(error, operation);
|
|
1917
2076
|
},
|
|
1918
2077
|
async setConversationFlags(conversationId, flags) {
|
|
1919
2078
|
const operation = "setConversationFlags";
|
|
1920
|
-
const
|
|
2079
|
+
const me = await actingUser(operation);
|
|
2080
|
+
const patch = { updated_by: me };
|
|
1921
2081
|
if (flags.isMuted !== void 0) patch["is_muted"] = flags.isMuted;
|
|
1922
2082
|
if (flags.isArchived !== void 0) patch["is_archived"] = flags.isArchived;
|
|
1923
|
-
const { error } = await db().from(TABLES.participants).update(patch).eq("conversation_id", conversationId).eq("user_id",
|
|
2083
|
+
const { error } = await db().from(TABLES.participants).update(patch).eq("conversation_id", conversationId).eq("user_id", me);
|
|
1924
2084
|
if (error !== null) throw normalizeMessagingError(error, operation);
|
|
1925
2085
|
},
|
|
1926
2086
|
async addMembers(conversationId, memberIds) {
|
|
1927
2087
|
const operation = "addMembers";
|
|
2088
|
+
const me = await actingUser(operation);
|
|
1928
2089
|
const { error } = await db().from(TABLES.participants).insert(
|
|
1929
2090
|
memberIds.map((userId) => ({
|
|
1930
2091
|
conversation_id: conversationId,
|
|
1931
2092
|
user_id: userId,
|
|
1932
2093
|
role: "member",
|
|
1933
2094
|
organization_id: org,
|
|
1934
|
-
created_by:
|
|
2095
|
+
created_by: me
|
|
1935
2096
|
}))
|
|
1936
2097
|
);
|
|
1937
2098
|
if (error !== null) throw normalizeMessagingError(error, operation);
|
|
1938
2099
|
},
|
|
1939
2100
|
async removeMember(conversationId, memberId) {
|
|
1940
2101
|
const operation = "removeMember";
|
|
1941
|
-
const
|
|
2102
|
+
const me = await actingUser(operation);
|
|
2103
|
+
const { error } = await db().from(TABLES.participants).update({ deleted_at: (/* @__PURE__ */ new Date()).toISOString(), updated_by: me }).eq("conversation_id", conversationId).eq("user_id", memberId);
|
|
1942
2104
|
if (error !== null) throw normalizeMessagingError(error, operation);
|
|
1943
2105
|
},
|
|
1944
2106
|
async setMemberRole(conversationId, memberId, role) {
|
|
1945
2107
|
const operation = "setMemberRole";
|
|
1946
|
-
const
|
|
2108
|
+
const me = await actingUser(operation);
|
|
2109
|
+
const { error } = await db().from(TABLES.participants).update({ role, updated_by: me }).eq("conversation_id", conversationId).eq("user_id", memberId);
|
|
1947
2110
|
if (error !== null) throw normalizeMessagingError(error, operation);
|
|
1948
2111
|
},
|
|
1949
2112
|
getUser(userId) {
|
|
@@ -1989,6 +2152,7 @@ export {
|
|
|
1989
2152
|
MESSAGING_SCHEMA,
|
|
1990
2153
|
MessagingError,
|
|
1991
2154
|
RPCS,
|
|
2155
|
+
SERVER_SESSION_IDENTITY,
|
|
1992
2156
|
TABLES,
|
|
1993
2157
|
asClientMessageId,
|
|
1994
2158
|
asConversationId,
|
|
@@ -2005,6 +2169,7 @@ export {
|
|
|
2005
2169
|
createMessagingStore,
|
|
2006
2170
|
createOutbox,
|
|
2007
2171
|
createReadCache,
|
|
2172
|
+
createSessionIdentity,
|
|
2008
2173
|
createWebOutboxStorage,
|
|
2009
2174
|
effectiveActorKey,
|
|
2010
2175
|
extractReferences,
|