mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🔗 feat: Shared Conversation Badge and Stable Share Links (#14712)
* feat: improve shared conversation links * test: Cover Shared Link Lifecycle * test: Cover Shared File Snapshots * fix: address review findings on shared links Stop double-decoding the conversation search term. Express already decodes req.query, so the route's extra decodeURIComponent threw URIError on any term containing a bare percent sign and mangled percent-escape-looking text. The sidebar already sent the term raw, so this failed there too. Advance a share's stored target to its branch tail when an update omits one. Updating from the conversation list could not resolve the tail and reused the stored target verbatim, silently republishing the same snapshot instead of the turns added since. Require revalidation on shared files. Updates now keep the shareId, so the file URL no longer changes and a cached response could outlive a revoked share-files choice; an ETag over the pinned snapshot fields keeps unchanged files on 304. * fix: keep the shared badge across conversation cache replacements isShared is derived per list request and absent from single-conversation payloads, so rename, pin, and the SSE conversation updates dropped it when they swapped a server response into the sidebar cache, hiding the badge until an unrelated list refetch. Carry the cached value forward in updateConvoInAllQueries so every replacing caller is covered, while an explicit value still wins. * test: mock syncStaticTools in server boot specs initializeMCPs now calls syncStaticTools when no MCP servers are configured, but the server boot specs stub ~/server/services/Config without it. Post-listen initialization threw, hit process.exit(1), and took the jest worker down until it exceeded the retry limit. * fix: address codex findings on the shared DataTable and file ETag Keep the published DataTable export bound to the legacy component and ship the design-system table as VirtualizedDataTable, so external consumers of @librechat/client keep the props they compile against. Fold the snapshot's stored location into the shared-file ETag, so a re-published output that keeps its size and revision but moves its object no longer revalidates to a stale 304. Auto-fill the table when a first page is too short to overflow its container, since pagination is otherwise only reachable through the scroll handler. * fix: re-scope share grants before publishing and retry stalled auto-fill Move the shared-link ACL expiration write ahead of the content update. The shareId survives an update, so a failed ACL write after the write-through left the new messages and file snapshot readable at the same URL while the owner saw a 500. Retry a rejected auto-fill fetch up to three times: an unscrollable table has no scroll event to fall back on, and the sentinel alone would strand it on the first page. * fix: follow regenerated branches and pin forks to the payload they saw advanceTargetToBranchTail only walked descendants, so a target replaced by a regeneration (a sibling, not a child) left the update parked on the obsolete branch and published none of the turns that followed. A childless target now hops once to the newest sibling the conversation continued under. A shareId survives an update, so an owner republishing between a viewer's load and their Continue click would resolve targetMessageIndex against different messages. The fork request now carries the payload's updatedAt and is rejected with 409 when it no longer matches; the viewer gets the current version pulled in and can retry. * fix: keep table sorting and legacy backfills from breaking share flows Restore the union formatting a local lint-staged prettier collapsed in data-provider types, which broke the CI lint run. Header clicks now toggle direction instead of cycling through an unsorted state, which the controlled tables translated straight back into the default and made one direction unreachable. Re-arm the auto-fill guard on a sort change: a re-sorted first page arrives with the same row count, and the guard would otherwise suppress paging on a container that still cannot scroll. Lazy fileSnapshots backfills no longer touch updatedAt. That timestamp is the revision a viewer's fork is validated against, so a legacy share's first read would have made the Continue click that followed it fail with a 409. * fix: break pagination ties by id and reset share state per conversation Both list cursors marked a page boundary with values that repeat: conversations by (sort field, updatedAt) and shared links by the sort field alone. Imported chats share a title and a timestamp, so every row tied with the boundary was skipped. Both now carry the boundary row's _id and sort by it last, and the shared-links cursor is an opaque composite the route still validates before querying. The share dialog outlives a switch between conversations, so a link with files disabled left the next conversation's dialog showing the switch off and quietly published without files. The stored choice now falls back to the enabled default, and a stale link no longer sits in the copy field. * fix: keep titleless shared links in the paginated list A share has no title default, and BSON orders a missing title before every string, so encoding the boundary as an empty string skipped the remaining untitled links when sorting Name ascending and re-admitted all of them descending. The cursor now carries the boundary's null rather than flattening it, and because $lt and $gt are type-bracketed against a string, descending adds an explicit clause for the untitled tail that a string comparison can never reach. * style: sort share method imports * fix: fail closed on orphaned share targets and guard snapshot backfills getMessagesUpToTarget walked levels from the roots and returned everything it had accumulated when the target was never reached. An imported or partially deleted branch whose parent is missing therefore published the whole conversation instead of the selected branch; the walk now returns nothing unless it actually reaches the target. A lazy backfill wrote fileSnapshots unconditionally, so a viewer's first read of a legacy link could land after a republish and restore the snapshot it replaced, re-authorizing the stable URL of a file the owner had just removed. The write is now conditional on the link still having no snapshot, and the stored one wins any race. Regenerating a message above the shared tail leaves the whole stored branch childless, so the target walk now climbs to the closest ancestor the conversation continued under instead of stopping at the stored tail's own siblings. Changing the search or sort also returns the table's viewport to the top, since the query holds the previous rows while it refetches. * fix: page through titleless rows on both sides of the cursor The route validator still required a string primary, so the composite cursor the data layer issues for a titleless boundary came back as a 400 and the shared-links table stopped at that page. Conversations had the same type-bracketing gap the shared links just closed: a name-sorted page could not reach conversations with no title, since a comparison against a string never matches a missing field. The cursor now carries the null and the filter spells out the titleless clauses for both directions. * fix: keep the share badge read-only and refresh rows on cell changes ensureLinkPermissions re-granted the owner ACL entry on every call, so rendering the header's shared badge turned ordinary navigation into a permission write. It now checks for the grant first and only migrates a link that still lacks one. A fork carrying a positional target but no revision falls back to the whole share, since nothing proves which payload the index was counted against. The memoized table row compared row data and selection only, so a cell rendering external state (the archived list's pending Restore, for one) kept its stale rendering until the row object itself moved; rows now also compare a marker that moves with the column definitions. * fix: keep the shared badge honest when a delete fails or a link remains A failed delete left the conversation looking unshared: the optimistic snapshot covered only the shared-link queries, not the conversation caches the badge reads. The cleared conversations are now restored with the rest. A conversation can hold one link per target message, so clearing the badge on delete is a guess. The conversation list is invalidated once the mutation settles, letting the server decide from the links that are actually left. * fix: refetch every cached conversation page after deleting a link The invalidation was pinned to page zero, so a conversation cached further down the sidebar kept the badge the optimistic update had already cleared even when another targeted link survived. * fix: treat a failed page fetch as a failed auto-fill React Query resolves fetchNextPage with an error result instead of rejecting, so the rejection handler never ran: the guard stayed armed on the unchanged row count and an unscrollable table could never reach the next page. * refactor: move the share request helpers into the typed backend Cursor validation, page-size clamping and the shared-file cache validator were plain backend logic sitting in the legacy JS route. They now live in packages/api with their own tests, and the route keeps only the Express-side wiring: reading query params, mapping domain error codes to status codes, and writing the response. Also carries the requested file choice into the cache entry the create and update mutations synthesize, since the response never echoes it and the dialog reads a resolved entry with no choice as the enabled default. * fix: hold auto-fill while the replacement page is in flight A search or sort swap keeps the previous rows and hasNextPage on screen while the new first page loads, so the re-armed auto-fill asked for page two against a query that was still fetching its first. An infinite query runs one fetch at a time, so that request could cancel or interfere with the one already out. Both tables now pass their fetching state and the guard waits for it. * fix: stop advertising links a deployment no longer serves The sidebar badge rendered from the derived flag alone, so a deployment that turned shared links off still told owners a link was live while the public routes were unregistered. The share dialog called the link a snapshot, but the payload populates the referenced message documents on every request: an edit to an already-shared message is visible immediately, and Update only adds newly referenced ones. The copy now says that. Scroll pagination inspects a resolved error the way auto-fill already does, since React Query reports a failed page that way instead of rejecting. * a11y: gate the shared conversation label on the feature flag The icon stopped rendering when a deployment turns shared links off, but the row still announced the conversation as shared to screen readers. Both now read the same condition. * fix: accept long title cursors and stop badge work the feature disables The cursor cap was tight enough that a Name-sorted page ending on a long title produced a nextCursor the next request rejected, stranding the rest of the list. It now sits well clear of anything the server can issue. The conversation list skipped straight into the shared-link lookup even where ALLOW_SHARED_LINKS is off, paying a round trip on the sidebar's first page for a badge that is never rendered. A regeneration is newer than what it replaced, so only a newer sibling counts: an older one that still has follow-ups is the branch the target was regenerated away from, and resuming there published turns the target had excluded. * fix: hold scroll pagination while a replacement page loads Resetting the viewport to the top after a search or sort change fires a scroll event, and the retained previous rows still report another page, so the handler asked for page two of a query that was still loading page one. * fix: keep the legacy share migration ahead of the owner-grant shortcut A legacy row keeps its marker until every grant it needs exists, so an owner grant on its own is not proof the migration finished. Reading the marker first means a half-migrated public link still gets its public grant, while a fully migrated one keeps the read-only settled path the badge lookup depends on.
This commit is contained in:
parent
5c939d129b
commit
152dcf4721
57 changed files with 4279 additions and 820 deletions
|
|
@ -445,6 +445,48 @@ describe('Convos Routes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('GET / search handling', () => {
|
||||
const { getConvosByCursor } = require('~/models');
|
||||
|
||||
beforeEach(() => {
|
||||
getConvosByCursor.mockResolvedValue({ conversations: [], nextCursor: null });
|
||||
});
|
||||
|
||||
/** Express already percent-decodes `req.query`, so decoding a second time in the route
|
||||
* threw URIError on any term containing a bare `%` and mangled `%xx`-looking text. */
|
||||
it('accepts a search term containing a literal percent sign', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/convos')
|
||||
.query({ isArchived: 'true', search: '100% ready' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(getConvosByCursor).toHaveBeenCalledWith(
|
||||
'test-user-123',
|
||||
expect.objectContaining({ search: '100% ready' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes percent-escape-looking text through without decoding it', async () => {
|
||||
const response = await request(app).get('/api/convos').query({ search: 'a%41b' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(getConvosByCursor).toHaveBeenCalledWith(
|
||||
'test-user-123',
|
||||
expect.objectContaining({ search: 'a%41b' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a whitespace-only search as no search', async () => {
|
||||
const response = await request(app).get('/api/convos').query({ search: ' ' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(getConvosByCursor).toHaveBeenCalledWith(
|
||||
'test-user-123',
|
||||
expect.objectContaining({ search: undefined }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /archive', () => {
|
||||
it('should archive a conversation successfully', async () => {
|
||||
const mockConversationId = 'conv-123';
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ const mockCanAccessSharedLink = jest.fn((req, _res, next) => {
|
|||
});
|
||||
const mockGetAppConfig = jest.fn();
|
||||
const mockGetTenantId = jest.fn(() => undefined);
|
||||
const mockParseSharedLinksPageSize = jest.fn(() => 10);
|
||||
const mockIsValidSharedLinksCursor = jest.fn(() => true);
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
isEnabled: jest.fn(() => true),
|
||||
|
|
@ -27,6 +29,13 @@ jest.mock('@librechat/api', () => ({
|
|||
deleteSharedLinkWithCleanup: jest.fn(),
|
||||
getSharedLinkExpiration: (...args) => mockGetSharedLinkExpiration(...args),
|
||||
isActiveExpirationDate: jest.fn((expiredAt) => expiredAt > new Date()),
|
||||
/* The list/query helpers are behaviour-tested in `@librechat/api`; the route only has
|
||||
to be wired to them, so they stand in as doubles here. */
|
||||
parseSharedLinksPageSize: (...args) => mockParseSharedLinksPageSize(...args),
|
||||
isValidSharedLinksCursor: (...args) => mockIsValidSharedLinksCursor(...args),
|
||||
buildShareFileEtag: (file) =>
|
||||
`"share-${file.file_id}-${file.previewRevision ?? 0}-${file.bytes ?? 0}-${file.filepath ?? ''}"`,
|
||||
MAX_SHARED_LINK_SEARCH_LENGTH: 256,
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
|
|
@ -133,6 +142,7 @@ const {
|
|||
getSharedMessages,
|
||||
createSharedLink,
|
||||
updateSharedLink,
|
||||
getSharedLinks,
|
||||
getSharedLinkFile,
|
||||
backfillSharedLinkFiles,
|
||||
getRoleByName,
|
||||
|
|
@ -233,6 +243,59 @@ describe('share routes', () => {
|
|||
expect(response.headers['cache-control']).toBe('private, no-store');
|
||||
});
|
||||
|
||||
it('normalizes shared-link list parameters without double-decoding search text', async () => {
|
||||
getSharedLinks.mockResolvedValue({ links: [], hasNextPage: false });
|
||||
mockParseSharedLinksPageSize.mockReturnValueOnce(100);
|
||||
const cursor = '2030-01-01T00:00:00.000Z';
|
||||
|
||||
const response = await request(buildApp()).get(
|
||||
`/api/share?pageSize=1000&sortBy=createdAt&sortDirection=asc&search=100%25%20ready&cursor=${encodeURIComponent(cursor)}`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(getSharedLinks).toHaveBeenCalledWith(
|
||||
'user-123',
|
||||
cursor,
|
||||
100,
|
||||
'createdAt',
|
||||
'asc',
|
||||
'100% ready',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a cursor the validator refuses before querying', async () => {
|
||||
mockIsValidSharedLinksCursor.mockReturnValueOnce(false);
|
||||
|
||||
const response = await request(buildApp()).get('/api/share?cursor=not-a-date');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(mockIsValidSharedLinksCursor).toHaveBeenCalledWith('not-a-date', 'createdAt');
|
||||
expect(getSharedLinks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts the composite cursor issued for a titleless page', async () => {
|
||||
getSharedLinks.mockResolvedValue({ links: [], hasNextPage: false });
|
||||
const cursor = Buffer.from(
|
||||
JSON.stringify({ primary: null, id: '0123456789abcdef01234567' }),
|
||||
).toString('base64');
|
||||
|
||||
const response = await request(buildApp()).get(
|
||||
`/api/share?sortBy=title&cursor=${encodeURIComponent(cursor)}`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(getSharedLinks).toHaveBeenCalledWith('user-123', cursor, 10, 'title', 'desc', undefined);
|
||||
});
|
||||
|
||||
it('does not expose internal list errors in the response', async () => {
|
||||
getSharedLinks.mockRejectedValue(new Error('mongodb.internal:27017'));
|
||||
|
||||
const response = await request(buildApp()).get('/api/share');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ message: 'Error getting shared links' });
|
||||
});
|
||||
|
||||
it('expires new shares for retained non-temporary conversations', async () => {
|
||||
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
|
||||
createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' });
|
||||
|
|
@ -291,6 +354,32 @@ describe('share routes', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('rejects invalid create options before resolving retention', async () => {
|
||||
const invalidTarget = await request(buildApp())
|
||||
.post('/api/share/convo-123')
|
||||
.send({ targetMessageId: '' });
|
||||
const invalidSnapshot = await request(buildApp())
|
||||
.post('/api/share/convo-123')
|
||||
.send({ snapshotFiles: 'false' });
|
||||
|
||||
expect(invalidTarget.status).toBe(400);
|
||||
expect(invalidSnapshot.status).toBe(400);
|
||||
expect(mockGetSharedLinkExpiration).not.toHaveBeenCalled();
|
||||
expect(createSharedLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps an existing-share domain error to conflict', async () => {
|
||||
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
|
||||
createSharedLink.mockRejectedValue(
|
||||
Object.assign(new Error('Share already exists'), { code: 'SHARE_EXISTS' }),
|
||||
);
|
||||
|
||||
const response = await request(buildApp()).post('/api/share/convo-123').send({});
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(response.body).toEqual({ message: 'Share already exists' });
|
||||
});
|
||||
|
||||
it('does not snapshot files when the user opts out (snapshotFiles=false)', async () => {
|
||||
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
|
||||
createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' });
|
||||
|
|
@ -327,7 +416,9 @@ describe('share routes', () => {
|
|||
});
|
||||
|
||||
it('passes the snapshotFiles opt-out through on update', async () => {
|
||||
mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
|
||||
mongoose.models.SharedLink.findOne.mockReturnValue(
|
||||
lean({ _id: 'link-456', conversationId: 'convo-123' }),
|
||||
);
|
||||
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
|
||||
updateSharedLink.mockResolvedValue({ _id: 'link-456', shareId: 'share-456' });
|
||||
|
||||
|
|
@ -367,7 +458,9 @@ describe('share routes', () => {
|
|||
});
|
||||
|
||||
it('expires updated shares for retained non-temporary conversations', async () => {
|
||||
mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
|
||||
mongoose.models.SharedLink.findOne.mockReturnValue(
|
||||
lean({ _id: 'link-456', conversationId: 'convo-123' }),
|
||||
);
|
||||
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
|
||||
updateSharedLink.mockResolvedValue({ _id: 'link-456', shareId: 'share-456' });
|
||||
|
||||
|
|
@ -403,8 +496,25 @@ describe('share routes', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('does not re-publish content when re-scoping the grants fails', async () => {
|
||||
mongoose.models.SharedLink.findOne.mockReturnValue(
|
||||
lean({ _id: 'link-456', conversationId: 'convo-123' }),
|
||||
);
|
||||
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
|
||||
mockUpdateSharedLinkPermissionsExpiration.mockRejectedValueOnce(new Error('acl down'));
|
||||
|
||||
const response = await request(buildApp()).patch('/api/share/share-123');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
// The shareId is stable, so publishing first would expose the update behind
|
||||
// the existing grants while the owner is told the update failed.
|
||||
expect(updateSharedLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects updated shares when the retained conversation expired', async () => {
|
||||
mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
|
||||
mongoose.models.SharedLink.findOne.mockReturnValue(
|
||||
lean({ _id: 'link-456', conversationId: 'convo-123' }),
|
||||
);
|
||||
mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration);
|
||||
updateSharedLink.mockResolvedValue({ shareId: 'share-456' });
|
||||
|
||||
|
|
@ -415,7 +525,9 @@ describe('share routes', () => {
|
|||
});
|
||||
|
||||
it('rejects updated shares for expired conversations in all retention mode', async () => {
|
||||
mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
|
||||
mongoose.models.SharedLink.findOne.mockReturnValue(
|
||||
lean({ _id: 'link-456', conversationId: 'convo-123' }),
|
||||
);
|
||||
mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration);
|
||||
updateSharedLink.mockResolvedValue({ shareId: 'share-456' });
|
||||
|
||||
|
|
@ -432,7 +544,9 @@ describe('share routes', () => {
|
|||
});
|
||||
|
||||
it('clears updated share expiration when the conversation is no longer retained', async () => {
|
||||
mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
|
||||
mongoose.models.SharedLink.findOne.mockReturnValue(
|
||||
lean({ _id: 'link-456', conversationId: 'convo-123' }),
|
||||
);
|
||||
mockGetSharedLinkExpiration.mockResolvedValue(null);
|
||||
updateSharedLink.mockResolvedValue({ _id: 'link-456', shareId: 'share-456' });
|
||||
|
||||
|
|
@ -441,11 +555,24 @@ describe('share routes', () => {
|
|||
expect(response.status).toBe(200);
|
||||
expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, null, true);
|
||||
expect(mockUpdateSharedLinkPermissionsExpiration).toHaveBeenCalledWith('link-456', null);
|
||||
expect(mockSharedLinksAccess).not.toHaveBeenCalled();
|
||||
expect(mockSharedLinksAccess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('gates updates on the CREATE permission so revoking it stops link updates', async () => {
|
||||
mockSharedLinksAccess.mockImplementationOnce((_req, res) =>
|
||||
res.status(403).json({ message: 'Forbidden' }),
|
||||
);
|
||||
|
||||
const response = await request(buildApp()).patch('/api/share/share-123');
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(updateSharedLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves updated share expiration when the conversation cannot be found', async () => {
|
||||
mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
|
||||
mongoose.models.SharedLink.findOne.mockReturnValue(
|
||||
lean({ _id: 'link-456', conversationId: 'convo-123' }),
|
||||
);
|
||||
mockGetSharedLinkExpiration.mockResolvedValue(undefined);
|
||||
updateSharedLink.mockResolvedValue({ shareId: 'share-456' });
|
||||
|
||||
|
|
@ -464,7 +591,9 @@ describe('share routes', () => {
|
|||
|
||||
it('clears updated share expiration when creating a new expiration throws', async () => {
|
||||
const error = new Error('bad config');
|
||||
mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
|
||||
mongoose.models.SharedLink.findOne.mockReturnValue(
|
||||
lean({ _id: 'link-456', conversationId: 'convo-123' }),
|
||||
);
|
||||
mockGetSharedLinkExpiration.mockImplementationOnce(async (_input, dependencies) => {
|
||||
dependencies.logger.error('[getSharedLinkExpiration] Error creating expiration date:', error);
|
||||
return null;
|
||||
|
|
@ -483,7 +612,9 @@ describe('share routes', () => {
|
|||
});
|
||||
|
||||
it('updates share target message while applying retention expiration', async () => {
|
||||
mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
|
||||
mongoose.models.SharedLink.findOne.mockReturnValue(
|
||||
lean({ _id: 'link-456', conversationId: 'convo-123' }),
|
||||
);
|
||||
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
|
||||
updateSharedLink.mockResolvedValue({ shareId: 'share-456', targetMessageId: 'msg-456' });
|
||||
|
||||
|
|
@ -510,6 +641,27 @@ describe('share routes', () => {
|
|||
expect(updateSharedLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects invalid snapshot options on update', async () => {
|
||||
const response = await request(buildApp())
|
||||
.patch('/api/share/share-123')
|
||||
.send({ snapshotFiles: 'false' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(updateSharedLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps a missing-share update domain error to not found', async () => {
|
||||
mongoose.models.SharedLink.findOne.mockReturnValue(lean(null));
|
||||
updateSharedLink.mockRejectedValue(
|
||||
Object.assign(new Error('Share not found'), { code: 'SHARE_NOT_FOUND' }),
|
||||
);
|
||||
|
||||
const response = await request(buildApp()).patch('/api/share/share-123').send({});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ message: 'Share not found' });
|
||||
});
|
||||
|
||||
it('allows deleting existing shares without CREATE permission gate', async () => {
|
||||
deleteSharedLinkWithCleanup.mockResolvedValue({ shareId: 'share-123' });
|
||||
|
||||
|
|
@ -519,6 +671,15 @@ describe('share routes', () => {
|
|||
expect(mockSharedLinksAccess).not.toHaveBeenCalled();
|
||||
expect(deleteSharedLinkWithCleanup).toHaveBeenCalledWith('user-123', 'share-123');
|
||||
});
|
||||
|
||||
it('returns an internal error status when deletion fails', async () => {
|
||||
deleteSharedLinkWithCleanup.mockRejectedValue(new Error('database unavailable'));
|
||||
|
||||
const response = await request(buildApp()).delete('/api/share/share-123');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ message: 'Error deleting shared link' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('share fork route', () => {
|
||||
|
|
@ -537,7 +698,7 @@ describe('share fork route', () => {
|
|||
buildApp({ user: { id: 'user-123', role: 'USER', tenantId: 'tenant-viewer' } }),
|
||||
)
|
||||
.post('/api/share/share-123/fork')
|
||||
.send({ targetMessageIndex: 3 });
|
||||
.send({ targetMessageIndex: 3, shareRevision: '2026-01-01T00:00:00.000Z' });
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toEqual(forkResult);
|
||||
|
|
@ -548,6 +709,7 @@ describe('share fork route', () => {
|
|||
userRole: 'USER',
|
||||
userTenantId: 'tenant-viewer',
|
||||
targetMessageIndex: 3,
|
||||
shareRevision: '2026-01-01T00:00:00.000Z',
|
||||
snapshotFiles: true,
|
||||
});
|
||||
});
|
||||
|
|
@ -581,6 +743,19 @@ describe('share fork route', () => {
|
|||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it('answers 409 when the viewer forks a payload the owner has republished', async () => {
|
||||
const conflict = new Error('Shared link was updated');
|
||||
conflict.code = 'SHARE_REVISION_MISMATCH';
|
||||
forkSharedConversation.mockRejectedValue(conflict);
|
||||
|
||||
const response = await request(buildApp())
|
||||
.post('/api/share/share-123/fork')
|
||||
.send({ targetMessageIndex: 3, shareRevision: '2026-01-01T00:00:00.000Z' });
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(response.body).toEqual({ message: 'Shared link was updated' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('share-scoped file routes', () => {
|
||||
|
|
@ -618,6 +793,118 @@ describe('share-scoped file routes', () => {
|
|||
expect(backfillSharedLinkFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires revalidation so a revoked link cannot be served from cache', async () => {
|
||||
const getDownloadStream = jest.fn(async () => Readable.from(['file-bytes']));
|
||||
mockGetStrategyFunctions.mockReturnValue({ getDownloadStream });
|
||||
getSharedLinkFile.mockResolvedValue({
|
||||
file: {
|
||||
file_id: 'file-1',
|
||||
source: 'local',
|
||||
filepath: '/images/owner/pic.png',
|
||||
type: 'image/png',
|
||||
filename: 'pic.png',
|
||||
bytes: 1234,
|
||||
},
|
||||
hasSnapshots: true,
|
||||
});
|
||||
|
||||
const response = await request(buildApp()).get('/api/share/share-123/files/file-1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// The shareId is stable across updates now, so the URL alone can no longer bust caches.
|
||||
expect(response.headers['cache-control']).toBe('private, no-cache');
|
||||
expect(response.headers['etag']).toBeDefined();
|
||||
});
|
||||
|
||||
it('answers an unchanged snapshot with 304 instead of re-sending bytes', async () => {
|
||||
const getDownloadStream = jest.fn(async () => Readable.from(['file-bytes']));
|
||||
mockGetStrategyFunctions.mockReturnValue({ getDownloadStream });
|
||||
getSharedLinkFile.mockResolvedValue({
|
||||
file: {
|
||||
file_id: 'file-1',
|
||||
source: 'local',
|
||||
filepath: '/images/owner/pic.png',
|
||||
type: 'image/png',
|
||||
filename: 'pic.png',
|
||||
bytes: 1234,
|
||||
},
|
||||
hasSnapshots: true,
|
||||
});
|
||||
|
||||
const app = buildApp();
|
||||
const first = await request(app).get('/api/share/share-123/files/file-1');
|
||||
expect(first.status).toBe(200);
|
||||
|
||||
getDownloadStream.mockClear();
|
||||
const response = await request(app)
|
||||
.get('/api/share/share-123/files/file-1')
|
||||
.set('If-None-Match', first.headers['etag']);
|
||||
|
||||
expect(response.status).toBe(304);
|
||||
expect(getDownloadStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('changes the validator when the snapshot revision moves', async () => {
|
||||
const getDownloadStream = jest.fn(async () => Readable.from(['file-bytes']));
|
||||
mockGetStrategyFunctions.mockReturnValue({ getDownloadStream });
|
||||
const snapshot = {
|
||||
file_id: 'file-1',
|
||||
source: 'local',
|
||||
filepath: '/images/owner/pic.png',
|
||||
type: 'image/png',
|
||||
filename: 'pic.png',
|
||||
bytes: 1234,
|
||||
};
|
||||
getSharedLinkFile.mockResolvedValue({ file: snapshot, hasSnapshots: true });
|
||||
|
||||
const app = buildApp();
|
||||
const first = await request(app).get('/api/share/share-123/files/file-1');
|
||||
|
||||
// Must match the snapshot, or resolveShareFile 404s on the version mismatch first.
|
||||
getFiles.mockResolvedValue([{ status: 'ready', previewRevision: 7, bytes: 1234 }]);
|
||||
getSharedLinkFile.mockResolvedValue({
|
||||
file: { ...snapshot, previewRevision: 7 },
|
||||
hasSnapshots: true,
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/share/share-123/files/file-1')
|
||||
.set('If-None-Match', first.headers['etag']);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['etag']).not.toBe(first.headers['etag']);
|
||||
});
|
||||
|
||||
it('changes the validator when a same-size replacement moves the stored object', async () => {
|
||||
const getDownloadStream = jest.fn(async () => Readable.from(['file-bytes']));
|
||||
mockGetStrategyFunctions.mockReturnValue({ getDownloadStream });
|
||||
const snapshot = {
|
||||
file_id: 'file-1',
|
||||
source: 'local',
|
||||
filepath: '/images/owner/plot.png?v=1',
|
||||
type: 'image/png',
|
||||
filename: 'plot.png',
|
||||
bytes: 1234,
|
||||
};
|
||||
getSharedLinkFile.mockResolvedValue({ file: snapshot, hasSnapshots: true });
|
||||
|
||||
const app = buildApp();
|
||||
const first = await request(app).get('/api/share/share-123/files/file-1');
|
||||
|
||||
// Re-published output: same file_id, same size, no revision, new stored path.
|
||||
getSharedLinkFile.mockResolvedValue({
|
||||
file: { ...snapshot, filepath: '/images/owner/plot.png?v=2' },
|
||||
hasSnapshots: true,
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/share/share-123/files/file-1')
|
||||
.set('If-None-Match', first.headers['etag']);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['etag']).not.toBe(first.headers['etag']);
|
||||
});
|
||||
|
||||
it('forces attachment for unsafe inline types (no stored XSS)', async () => {
|
||||
const getDownloadStream = jest.fn(async () => Readable.from(['<svg/>']));
|
||||
mockGetStrategyFunctions.mockReturnValue({ getDownloadStream });
|
||||
|
|
@ -658,6 +945,31 @@ describe('share-scoped file routes', () => {
|
|||
expect(response.headers['content-disposition']).toContain('attachment');
|
||||
});
|
||||
|
||||
it('returns 500 when the backing stream fails before sending bytes', async () => {
|
||||
const failingStream = new Readable({
|
||||
read() {
|
||||
this.destroy(new Error('storage unavailable'));
|
||||
},
|
||||
});
|
||||
mockGetStrategyFunctions.mockReturnValue({
|
||||
getDownloadStream: jest.fn(async () => failingStream),
|
||||
});
|
||||
getSharedLinkFile.mockResolvedValue({
|
||||
file: {
|
||||
file_id: 'file-1',
|
||||
source: 'local',
|
||||
filepath: '/uploads/owner/file-1',
|
||||
type: 'application/pdf',
|
||||
filename: 'report.pdf',
|
||||
},
|
||||
hasSnapshots: true,
|
||||
});
|
||||
|
||||
const response = await request(buildApp()).get('/api/share/share-123/files/file-1');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it('returns preview status read live from the file record', async () => {
|
||||
getSharedLinkFile.mockResolvedValue({
|
||||
file: { file_id: 'file-1', source: 'local' },
|
||||
|
|
|
|||
|
|
@ -39,7 +39,8 @@ router.get('/', async (req, res) => {
|
|||
const limit = parseInt(req.query.limit, 10) || 25;
|
||||
const cursor = req.query.cursor;
|
||||
const isArchived = isEnabled(req.query.isArchived);
|
||||
const search = req.query.search ? decodeURIComponent(req.query.search) : undefined;
|
||||
const search =
|
||||
typeof req.query.search === 'string' ? req.query.search.trim() || undefined : undefined;
|
||||
const sortBy = req.query.sortBy || 'updatedAt';
|
||||
const sortDirection = req.query.sortDirection || 'desc';
|
||||
const projectId = Array.isArray(req.query.projectId)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ const {
|
|||
updateSharedLinkPermissionsExpiration,
|
||||
isActiveExpirationDate,
|
||||
getSharedLinkExpiration,
|
||||
buildShareFileEtag,
|
||||
parseSharedLinksPageSize,
|
||||
isValidSharedLinksCursor,
|
||||
MAX_SHARED_LINK_SEARCH_LENGTH,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
logger,
|
||||
|
|
@ -46,6 +50,22 @@ const configMiddleware = require('~/server/middleware/config/app');
|
|||
const { getAppConfig } = require('~/server/services/Config/app');
|
||||
const router = express.Router();
|
||||
|
||||
const SHARE_SERVICE_ERROR_STATUS = {
|
||||
INVALID_PARAMS: 400,
|
||||
TARGET_MESSAGE_NOT_FOUND: 400,
|
||||
NO_MESSAGES: 400,
|
||||
CONVERSATION_NOT_FOUND: 404,
|
||||
SHARE_NOT_FOUND: 404,
|
||||
SHARE_EXISTS: 409,
|
||||
SHARE_REVISION_MISMATCH: 409,
|
||||
};
|
||||
|
||||
const sendShareServiceError = (res, error, fallbackMessage) => {
|
||||
const status = SHARE_SERVICE_ERROR_STATUS[error?.code] ?? 500;
|
||||
const message = status === 500 ? fallbackMessage : error.message;
|
||||
return res.status(status).json({ message });
|
||||
};
|
||||
|
||||
const checkSharedLinksAccess = generateCheckAccess({
|
||||
permissionType: PermissionTypes.SHARED_LINKS,
|
||||
permissions: [Permissions.CREATE],
|
||||
|
|
@ -179,6 +199,16 @@ const streamSharedFile = async (req, res, file, requestedDisposition) => {
|
|||
const source = file.source || FileSources.local;
|
||||
const { getDownloadStream, getDownloadURL } = getStrategyFunctions(source);
|
||||
|
||||
// An update keeps the shareId, so these URLs are stable across re-publishes. Without
|
||||
// revalidation a viewer's cached copy would outlive a revoked "share files" choice or a
|
||||
// replaced snapshot by the max-age.
|
||||
const etag = buildShareFileEtag(file);
|
||||
res.setHeader('ETag', etag);
|
||||
res.setHeader('Cache-Control', 'private, no-cache');
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
|
||||
// Inline only safe preview types; anything else is forced to attachment.
|
||||
const disposition =
|
||||
requestedDisposition === 'inline' && SAFE_INLINE_TYPES.has(file.type) ? 'inline' : 'attachment';
|
||||
|
|
@ -211,9 +241,6 @@ const streamSharedFile = async (req, res, file, requestedDisposition) => {
|
|||
// the local stream resolves the real filename, not a literal `*.png?v=...` path.
|
||||
const streamPath = (file.storageKey || file.filepath || '').split('?')[0];
|
||||
const fileStream = await getDownloadStream(req, streamPath);
|
||||
fileStream.on('error', (error) => {
|
||||
logger.error('[shareFileAccess] Stream error:', error);
|
||||
});
|
||||
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Content-Disposition', getContentDisposition(file.filename, disposition));
|
||||
|
|
@ -221,8 +248,33 @@ const streamSharedFile = async (req, res, file, requestedDisposition) => {
|
|||
'Content-Type',
|
||||
disposition === 'inline' ? file.type || 'application/octet-stream' : 'application/octet-stream',
|
||||
);
|
||||
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||
return fileStream.pipe(res);
|
||||
return new Promise((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
fileStream.removeListener('error', onError);
|
||||
res.removeListener('finish', onFinish);
|
||||
res.removeListener('close', onClose);
|
||||
};
|
||||
const onError = (error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
const onFinish = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onClose = () => {
|
||||
cleanup();
|
||||
if (!fileStream.destroyed) {
|
||||
fileStream.destroy();
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
fileStream.once('error', onError);
|
||||
res.once('finish', onFinish);
|
||||
res.once('close', onClose);
|
||||
fileStream.pipe(res);
|
||||
});
|
||||
};
|
||||
|
||||
if (allowSharedLinks) {
|
||||
|
|
@ -279,6 +331,7 @@ if (allowSharedLinks) {
|
|||
userRole: req.user.role,
|
||||
userTenantId: req.user.tenantId,
|
||||
targetMessageIndex: req.body?.targetMessageIndex,
|
||||
shareRevision: req.body?.shareRevision,
|
||||
// Viewer-independent: honor the global shared-file kill switch, matching
|
||||
// the GET share route so disabled file snapshots aren't copied into forks.
|
||||
snapshotFiles: !isFileSnapshotKillSwitchActive(),
|
||||
|
|
@ -286,10 +339,12 @@ if (allowSharedLinks) {
|
|||
if (!result) {
|
||||
return res.status(404).json({ message: 'Shared conversation not found' });
|
||||
}
|
||||
res.status(201).json(result);
|
||||
return res.status(201).json(result);
|
||||
} catch (error) {
|
||||
logger.error('Error forking shared conversation:', error);
|
||||
res.status(500).json({ message: 'Error forking shared conversation' });
|
||||
if (error?.code !== 'SHARE_REVISION_MISMATCH') {
|
||||
logger.error('Error forking shared conversation:', error);
|
||||
}
|
||||
return sendShareServiceError(res, error, 'Error forking shared conversation');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -357,8 +412,9 @@ if (allowSharedLinks) {
|
|||
} catch (error) {
|
||||
logger.error('[shareFileAccess] Error downloading shared file:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).send('Error downloading file');
|
||||
return res.status(500).send('Error downloading file');
|
||||
}
|
||||
res.destroy();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -379,8 +435,9 @@ if (allowSharedLinks) {
|
|||
} catch (error) {
|
||||
logger.error('[shareFileAccess] Error serving shared file:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).send('Error serving file');
|
||||
return res.status(500).send('Error serving file');
|
||||
}
|
||||
res.destroy();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -391,14 +448,31 @@ if (allowSharedLinks) {
|
|||
*/
|
||||
router.get('/', requireJwtAuth, async (req, res) => {
|
||||
try {
|
||||
const sortBy =
|
||||
typeof req.query.sortBy === 'string' && ['createdAt', 'title'].includes(req.query.sortBy)
|
||||
? req.query.sortBy
|
||||
: 'createdAt';
|
||||
const cursor = typeof req.query.cursor === 'string' ? req.query.cursor.trim() : undefined;
|
||||
const search = typeof req.query.search === 'string' ? req.query.search.trim() : undefined;
|
||||
|
||||
if (search && search.length > MAX_SHARED_LINK_SEARCH_LENGTH) {
|
||||
return res.status(400).json({
|
||||
message: `search must be ${MAX_SHARED_LINK_SEARCH_LENGTH} characters or fewer`,
|
||||
});
|
||||
}
|
||||
|
||||
if (cursor && !isValidSharedLinksCursor(cursor, sortBy)) {
|
||||
return res.status(400).json({ message: 'cursor is not valid for this sort' });
|
||||
}
|
||||
|
||||
const params = {
|
||||
pageParam: req.query.cursor,
|
||||
pageSize: Math.max(1, parseInt(req.query.pageSize) || 10),
|
||||
sortBy: ['createdAt', 'title'].includes(req.query.sortBy) ? req.query.sortBy : 'createdAt',
|
||||
pageParam: cursor,
|
||||
pageSize: parseSharedLinksPageSize(req.query.pageSize),
|
||||
sortBy,
|
||||
sortDirection: ['asc', 'desc'].includes(req.query.sortDirection)
|
||||
? req.query.sortDirection
|
||||
: 'desc',
|
||||
search: req.query.search ? decodeURIComponent(req.query.search.trim()) : undefined,
|
||||
search: search || undefined,
|
||||
};
|
||||
|
||||
const result = await getSharedLinks(
|
||||
|
|
@ -417,10 +491,7 @@ router.get('/', requireJwtAuth, async (req, res) => {
|
|||
});
|
||||
} catch (error) {
|
||||
logger.error('Error getting shared links:', error);
|
||||
res.status(500).json({
|
||||
message: 'Error getting shared links',
|
||||
error: error.message,
|
||||
});
|
||||
res.status(500).json({ message: 'Error getting shared links' });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -453,7 +524,17 @@ router.post(
|
|||
checkSharedLinksAccess,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { targetMessageId } = req.body;
|
||||
const { targetMessageId, snapshotFiles: requestedSnapshotFiles } = req.body ?? {};
|
||||
if (
|
||||
targetMessageId !== undefined &&
|
||||
(typeof targetMessageId !== 'string' || targetMessageId.trim().length === 0)
|
||||
) {
|
||||
return res.status(400).json({ message: 'targetMessageId must be a non-empty string' });
|
||||
}
|
||||
if (requestedSnapshotFiles !== undefined && typeof requestedSnapshotFiles !== 'boolean') {
|
||||
return res.status(400).json({ message: 'snapshotFiles must be a boolean' });
|
||||
}
|
||||
|
||||
const expiredAt = await resolveSharedLinkExpiration(req, req.params.conversationId);
|
||||
if (expiredAt != null && !isActiveExpirationDate(expiredAt)) {
|
||||
return res.status(404).end();
|
||||
|
|
@ -464,7 +545,7 @@ router.post(
|
|||
const grantPublic = sharedLinksPerms[Permissions.SHARE_PUBLIC] === true;
|
||||
// Per-link opt-out: snapshot only when the feature is enabled AND the user
|
||||
// did not uncheck "share files" (body flag absent defaults to enabled).
|
||||
const snapshotFiles = isFileSnapshotEnabled(req.config) && req.body?.snapshotFiles !== false;
|
||||
const snapshotFiles = isFileSnapshotEnabled(req.config) && requestedSnapshotFiles !== false;
|
||||
|
||||
const created = await createSharedLink(
|
||||
req.user.id,
|
||||
|
|
@ -481,51 +562,70 @@ router.post(
|
|||
}
|
||||
} catch (error) {
|
||||
logger.error('Error creating shared link:', error);
|
||||
res.status(500).json({ message: 'Error creating shared link' });
|
||||
return sendShareServiceError(res, error, 'Error creating shared link');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.patch('/:shareId', requireJwtAuth, configMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { targetMessageId } = req.body ?? {};
|
||||
if (targetMessageId !== undefined && typeof targetMessageId !== 'string') {
|
||||
return res.status(400).json({ message: 'targetMessageId must be a string' });
|
||||
}
|
||||
|
||||
let expiredAt;
|
||||
const SharedLink = mongoose.models.SharedLink;
|
||||
const existing = await SharedLink.findOne(
|
||||
{ shareId: req.params.shareId, user: req.user.id },
|
||||
'conversationId',
|
||||
).lean();
|
||||
if (existing?.conversationId) {
|
||||
expiredAt = await resolveSharedLinkExpiration(req, existing.conversationId);
|
||||
}
|
||||
if (expiredAt != null && !isActiveExpirationDate(expiredAt)) {
|
||||
return res.status(404).end();
|
||||
}
|
||||
|
||||
const updatedShare = await updateSharedLink(
|
||||
req.user.id,
|
||||
req.params.shareId,
|
||||
targetMessageId,
|
||||
expiredAt,
|
||||
isFileSnapshotEnabled(req.config) && req.body?.snapshotFiles !== false,
|
||||
);
|
||||
if (updatedShare) {
|
||||
if (updatedShare._id && expiredAt !== undefined) {
|
||||
await updateSharedLinkPermissionsExpiration(updatedShare._id, expiredAt);
|
||||
/** Updating or re-scoping a link re-publishes conversation content, so it is gated
|
||||
* on the same CREATE permission as POST; revoking CREATE must stop updates too.
|
||||
* DELETE stays ungated so an owner can always retract a link they no longer may create. */
|
||||
router.patch(
|
||||
'/:shareId',
|
||||
requireJwtAuth,
|
||||
configMiddleware,
|
||||
checkSharedLinksAccess,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { targetMessageId, snapshotFiles: requestedSnapshotFiles } = req.body ?? {};
|
||||
if (
|
||||
targetMessageId !== undefined &&
|
||||
(typeof targetMessageId !== 'string' || targetMessageId.trim().length === 0)
|
||||
) {
|
||||
return res.status(400).json({ message: 'targetMessageId must be a non-empty string' });
|
||||
}
|
||||
res.status(200).json(updatedShare);
|
||||
} else {
|
||||
res.status(404).end();
|
||||
if (requestedSnapshotFiles !== undefined && typeof requestedSnapshotFiles !== 'boolean') {
|
||||
return res.status(400).json({ message: 'snapshotFiles must be a boolean' });
|
||||
}
|
||||
|
||||
let expiredAt;
|
||||
const SharedLink = mongoose.models.SharedLink;
|
||||
const existing = await SharedLink.findOne(
|
||||
{ shareId: req.params.shareId, user: req.user.id },
|
||||
'conversationId',
|
||||
).lean();
|
||||
if (existing?.conversationId) {
|
||||
expiredAt = await resolveSharedLinkExpiration(req, existing.conversationId);
|
||||
}
|
||||
if (expiredAt != null && !isActiveExpirationDate(expiredAt)) {
|
||||
return res.status(404).end();
|
||||
}
|
||||
|
||||
// Re-scope the grants before re-publishing. The shareId survives an update, so a
|
||||
// failed ACL write after the write-through would leave the new messages and file
|
||||
// snapshot readable at the same URL while the owner is told the update failed.
|
||||
if (existing?._id && expiredAt !== undefined) {
|
||||
await updateSharedLinkPermissionsExpiration(existing._id, expiredAt);
|
||||
}
|
||||
|
||||
const updatedShare = await updateSharedLink(
|
||||
req.user.id,
|
||||
req.params.shareId,
|
||||
targetMessageId,
|
||||
expiredAt,
|
||||
isFileSnapshotEnabled(req.config) && requestedSnapshotFiles !== false,
|
||||
);
|
||||
if (!updatedShare) {
|
||||
return res.status(404).end();
|
||||
}
|
||||
|
||||
return res.status(200).json(updatedShare);
|
||||
} catch (error) {
|
||||
logger.error('Error updating shared link:', error);
|
||||
return sendShareServiceError(res, error, 'Error updating shared link');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error updating shared link:', error);
|
||||
res.status(500).json({ message: 'Error updating shared link' });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.delete('/:shareId', requireJwtAuth, async (req, res) => {
|
||||
try {
|
||||
|
|
@ -538,7 +638,7 @@ router.delete('/:shareId', requireJwtAuth, async (req, res) => {
|
|||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
logger.error('Error deleting shared link:', error);
|
||||
return res.status(400).json({ message: 'Error deleting shared link' });
|
||||
return res.status(500).json({ message: 'Error deleting shared link' });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -379,6 +379,14 @@ function stripSharedFileIds(message) {
|
|||
return sanitized;
|
||||
}
|
||||
|
||||
/** Compares a client-held share revision against the stored one, tolerating the
|
||||
* Date/ISO-string round trip through JSON. */
|
||||
function isSameRevision(storedUpdatedAt, clientRevision) {
|
||||
const stored = new Date(storedUpdatedAt ?? 0).getTime();
|
||||
const client = new Date(clientRevision).getTime();
|
||||
return Number.isFinite(stored) && Number.isFinite(client) && stored === client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forks a shared (sanitized) conversation into a fresh conversation owned by the requesting user.
|
||||
* Only the anonymized, allowlisted message fields returned by `getSharedMessages` are cloned,
|
||||
|
|
@ -390,6 +398,7 @@ function stripSharedFileIds(message) {
|
|||
* @param {string} [params.userRole] - The role of the requesting user, used to resolve the default model.
|
||||
* @param {string} [params.userTenantId] - Tenant of the requesting user. `canAccessSharedLink` runs this handler under the share owner's tenant so the share resolves, so the copy must be persisted (and its config/retention resolved) under the requesting user's tenant or it would be invisible (404) when they open it normally.
|
||||
* @param {number} [params.targetMessageIndex] - Index, within the shared payload, of the message at the tip of the branch the viewer has active. When set, only the direct path to that message is cloned so the fork continues the branch that was actually shown rather than the newest sibling. An index is used (not id or `createdAt`) because shared ids are re-anonymized per request while `getSharedMessages` returns a deterministic, stable order, so the same index resolves to the same message on the server.
|
||||
* @param {string} [params.shareRevision] - `updatedAt` of the payload the viewer is forking from. A shareId now survives an update, so an owner republishing between the GET and the fork would silently shift `targetMessageIndex` onto a different branch; a mismatch is rejected instead of cloning content the viewer never saw.
|
||||
* @param {boolean} [params.snapshotFiles] - When `false`, file/attachment metadata is omitted from the cloned messages, mirroring the GET share route so the global shared-file kill switch is honored.
|
||||
* @param {(userId: string, interfaceConfig?: object) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance.
|
||||
* @param {(options: object) => Promise<object>} [params.loadAppConfig] - Resolves the app config; injectable for tests. Called inside the requesting user's tenant context so retention policy is read from the viewer's tenant, not the share owner's.
|
||||
|
|
@ -402,6 +411,7 @@ async function forkSharedConversation({
|
|||
userRole,
|
||||
userTenantId,
|
||||
targetMessageIndex,
|
||||
shareRevision,
|
||||
snapshotFiles,
|
||||
builderFactory = createImportBatchBuilder,
|
||||
loadAppConfig = getAppConfig,
|
||||
|
|
@ -414,17 +424,31 @@ async function forkSharedConversation({
|
|||
return null;
|
||||
}
|
||||
|
||||
// The index below is positional against the payload the viewer holds, and the
|
||||
// shareId no longer rotates on update, so a republish between the GET and this
|
||||
// request would resolve it against different messages. Reject rather than fork
|
||||
// a branch the viewer never saw.
|
||||
if (shareRevision != null && !isSameRevision(share.updatedAt, shareRevision)) {
|
||||
const error = new Error('Shared link was updated');
|
||||
error.code = 'SHARE_REVISION_MISMATCH';
|
||||
throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared payload includes sibling branches. Reduce to the direct path of
|
||||
* the viewer's active message so the fork continues exactly the branch that
|
||||
* was shown; without this the default branch selection lands on the newest
|
||||
* sibling. The active tip is located by its index in the shared payload, which
|
||||
* `getSharedMessages` returns in a deterministic order (stored ref-array order)
|
||||
* — unlike ids (re-anonymized per request) or `createdAt` (can collide). Falls
|
||||
* `getSharedMessages` returns in a deterministic order (stored ref-array order),
|
||||
* unlike ids (re-anonymized per request) or `createdAt` (can collide). Falls
|
||||
* back to the full set when the index is absent or out of range.
|
||||
*/
|
||||
let sourceMessages = share.messages;
|
||||
if (
|
||||
// A positional target only means something against the payload the caller read;
|
||||
// with no revision proving which one that was, fall back to the whole share
|
||||
// rather than resolving the index against a snapshot they never saw.
|
||||
shareRevision != null &&
|
||||
Number.isInteger(targetMessageIndex) &&
|
||||
targetMessageIndex >= 0 &&
|
||||
targetMessageIndex < share.messages.length
|
||||
|
|
|
|||
|
|
@ -331,10 +331,13 @@ describe('forkSharedConversation', () => {
|
|||
},
|
||||
];
|
||||
|
||||
const SHARE_REVISION = '2026-01-01T00:00:00.000Z';
|
||||
|
||||
const mockShare = {
|
||||
shareId: 'share123',
|
||||
conversationId: 'convo_anon',
|
||||
title: 'Shared Title',
|
||||
updatedAt: new Date(SHARE_REVISION),
|
||||
messages: mockSharedMessages,
|
||||
};
|
||||
|
||||
|
|
@ -349,6 +352,42 @@ describe('forkSharedConversation', () => {
|
|||
bulkIncrementTagCounts.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
test('should reject a fork aimed at a payload the owner has since republished', async () => {
|
||||
getSharedMessages.mockResolvedValue({
|
||||
...mockShare,
|
||||
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
await expect(
|
||||
forkSharedConversation({
|
||||
shareId: 'share123',
|
||||
shareResourceId: 'resource123',
|
||||
requestUserId: 'user1',
|
||||
targetMessageIndex: 1,
|
||||
shareRevision: '2026-01-01T00:00:00.000Z',
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'SHARE_REVISION_MISMATCH' });
|
||||
|
||||
expect(bulkSaveMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should fork when the held revision still matches the published one', async () => {
|
||||
getSharedMessages.mockResolvedValue({
|
||||
...mockShare,
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
const result = await forkSharedConversation({
|
||||
shareId: 'share123',
|
||||
shareResourceId: 'resource123',
|
||||
requestUserId: 'user1',
|
||||
shareRevision: SHARE_REVISION,
|
||||
});
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(bulkSaveMessages).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should clone shared messages into a conversation owned by the requesting user', async () => {
|
||||
const result = await forkSharedConversation({
|
||||
shareId: 'share123',
|
||||
|
|
@ -591,6 +630,7 @@ describe('forkSharedConversation', () => {
|
|||
shareId: 'share123',
|
||||
requestUserId: 'user1',
|
||||
targetMessageIndex: 1,
|
||||
shareRevision: SHARE_REVISION,
|
||||
});
|
||||
|
||||
const savedTexts = bulkSaveMessages.mock.calls[0][0].map((message) => message.text);
|
||||
|
|
@ -628,6 +668,7 @@ describe('forkSharedConversation', () => {
|
|||
shareId: 'share123',
|
||||
requestUserId: 'user1',
|
||||
targetMessageIndex: 2,
|
||||
shareRevision: SHARE_REVISION,
|
||||
});
|
||||
|
||||
const savedTexts = bulkSaveMessages.mock.calls[0][0].map((message) => message.text);
|
||||
|
|
@ -640,11 +681,24 @@ describe('forkSharedConversation', () => {
|
|||
shareId: 'share123',
|
||||
requestUserId: 'user1',
|
||||
targetMessageIndex: 999,
|
||||
shareRevision: SHARE_REVISION,
|
||||
});
|
||||
|
||||
expect(bulkSaveMessages.mock.calls[0][0]).toHaveLength(mockSharedMessages.length);
|
||||
});
|
||||
|
||||
test('should ignore a positional target that comes without a revision', async () => {
|
||||
await forkSharedConversation({
|
||||
shareId: 'share123',
|
||||
requestUserId: 'user1',
|
||||
targetMessageIndex: 1,
|
||||
});
|
||||
|
||||
// Nothing proves which payload the index was read against, so the whole share
|
||||
// is cloned instead of a branch the caller may never have seen.
|
||||
expect(bulkSaveMessages.mock.calls[0][0]).toHaveLength(mockSharedMessages.length);
|
||||
});
|
||||
|
||||
test('should persist under the requesting user tenant, not the share tenant', async () => {
|
||||
const { tenantStorage, getTenantId } = require('@librechat/data-schemas');
|
||||
let tenantDuringSave;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useRecoilValue } from 'recoil';
|
|||
import * as Ariakit from '@ariakit/react';
|
||||
import { Upload, Share2 } from 'lucide-react';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { useGetSharedLinkQuery } from 'librechat-data-provider/react-query';
|
||||
import { DropdownPopup, TooltipAnchor, useMediaQuery } from '@librechat/client';
|
||||
import type * as t from '~/common';
|
||||
import ExportModal from '~/components/Nav/ExportConversation/ExportModal';
|
||||
|
|
@ -31,10 +32,14 @@ export default function ExportAndShareMenu({
|
|||
const conversation = useRecoilValue(store.conversationByIndex(0));
|
||||
|
||||
const exportable =
|
||||
conversation &&
|
||||
conversation != null &&
|
||||
conversation.conversationId != null &&
|
||||
conversation.conversationId !== 'new' &&
|
||||
conversation.conversationId !== 'search';
|
||||
const { data: share } = useGetSharedLinkQuery(conversation?.conversationId ?? '', {
|
||||
enabled: exportable && isSharedButtonEnabled,
|
||||
});
|
||||
const hasSharedLink = Boolean(share?.shareId);
|
||||
|
||||
if (exportable === false) {
|
||||
return null;
|
||||
|
|
@ -81,18 +86,29 @@ export default function ExportAndShareMenu({
|
|||
setIsOpen={setIsPopoverActive}
|
||||
trigger={
|
||||
<TooltipAnchor
|
||||
description={localize('com_endpoint_export_share')}
|
||||
description={localize(
|
||||
hasSharedLink ? 'com_ui_export_share_link_active' : 'com_endpoint_export_share',
|
||||
)}
|
||||
render={
|
||||
<Ariakit.MenuButton
|
||||
id="export-menu-button"
|
||||
aria-label="Export options"
|
||||
className="inline-flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-presentation text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
||||
aria-label={localize(
|
||||
hasSharedLink ? 'com_ui_export_share_link_active' : 'com_endpoint_export_share',
|
||||
)}
|
||||
className="relative inline-flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-presentation text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
||||
>
|
||||
<Share2
|
||||
className="icon-md text-text-primary"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
/>
|
||||
{hasSharedLink && (
|
||||
<span
|
||||
className="absolute -right-0.5 -top-0.5 size-2 rounded-full bg-status-info ring-2 ring-presentation"
|
||||
data-testid="header-shared-link-indicator"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import ExportAndShareMenu from '../ExportAndShareMenu';
|
||||
|
||||
let mockShareId: string | null = null;
|
||||
|
||||
jest.mock('recoil', () => ({
|
||||
useRecoilValue: () => ({ conversationId: 'conversation-1' }),
|
||||
}));
|
||||
|
||||
jest.mock('librechat-data-provider/react-query', () => ({
|
||||
useGetSharedLinkQuery: () => ({ data: { shareId: mockShareId } }),
|
||||
}));
|
||||
|
||||
jest.mock('@ariakit/react', () => ({
|
||||
MenuButton: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
DropdownPopup: ({ trigger }: { trigger: React.ReactNode }) => trigger,
|
||||
TooltipAnchor: ({ render }: { render: React.ReactNode }) => render,
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useHasAccess: () => true,
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock('~/components/Nav/ExportConversation/ExportModal', () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
jest.mock('~/components/Conversations/ConvoOptions', () => ({
|
||||
ShareButton: () => null,
|
||||
}));
|
||||
|
||||
jest.mock('~/store', () => ({
|
||||
__esModule: true,
|
||||
default: { conversationByIndex: () => ({}) },
|
||||
}));
|
||||
|
||||
describe('ExportAndShareMenu link status', () => {
|
||||
beforeEach(() => {
|
||||
mockShareId = null;
|
||||
});
|
||||
|
||||
it('shows a blue circular indicator when the conversation has a link', () => {
|
||||
mockShareId = 'share-1';
|
||||
|
||||
render(<ExportAndShareMenu isSharedButtonEnabled={true} />);
|
||||
|
||||
expect(screen.getByTestId('header-shared-link-indicator')).toHaveClass(
|
||||
'rounded-full',
|
||||
'bg-status-info',
|
||||
'-right-0.5',
|
||||
'-top-0.5',
|
||||
'size-2',
|
||||
);
|
||||
expect(screen.getByRole('button')).toHaveAttribute(
|
||||
'aria-label',
|
||||
'com_ui_export_share_link_active',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the default share control when the conversation has no link', () => {
|
||||
render(<ExportAndShareMenu isSharedButtonEnabled={true} />);
|
||||
|
||||
expect(screen.queryByTestId('header-shared-link-indicator')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'com_endpoint_export_share');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
import React, { memo, useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { Pin } from 'lucide-react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Link2, Pin } from 'lucide-react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import { Spinner, useToastContext, useMediaQuery } from '@librechat/client';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import { useGetStartupConfig, useUpdateConversationMutation } from '~/data-provider';
|
||||
import { useNavigateToConvo, useLocalize, useShiftKey } from '~/hooks';
|
||||
import ConversationEndpointIcon from './ConversationEndpointIcon';
|
||||
import { useUpdateConversationMutation } from '~/data-provider';
|
||||
import { areConversationRenderPropsEqual } from './utils';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { ConvoOptions } from './ConvoOptions';
|
||||
|
|
@ -37,6 +37,11 @@ function Conversation({
|
|||
const updateConvoMutation = useUpdateConversationMutation(currentConvoId ?? '');
|
||||
const activeConvos = useRecoilValue(store.allConversationsSelector);
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
/* A deployment with shared links off leaves existing links in the database but stops
|
||||
serving them, so the row must not advertise one that no longer resolves. */
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const sharedLinksEnabled = startupConfig?.sharedLinksEnabled === true;
|
||||
const isSharedBadgeVisible = conversation.isShared === true && sharedLinksEnabled;
|
||||
const isShiftHeld = useShiftKey();
|
||||
const { conversationId, title = '' } = conversation;
|
||||
|
||||
|
|
@ -215,9 +220,15 @@ function Conversation({
|
|||
)}
|
||||
role="button"
|
||||
tabIndex={renaming ? -1 : 0}
|
||||
aria-label={localize('com_ui_conversation_label', {
|
||||
title: title || localize('com_ui_untitled'),
|
||||
})}
|
||||
aria-label={
|
||||
isSharedBadgeVisible
|
||||
? localize('com_ui_conversation_label_shared', {
|
||||
title: title || localize('com_ui_untitled'),
|
||||
})
|
||||
: localize('com_ui_conversation_label', {
|
||||
title: title || localize('com_ui_untitled'),
|
||||
})
|
||||
}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onFocus={handleMouseEnter}
|
||||
|
|
@ -265,6 +276,9 @@ function Conversation({
|
|||
<ConversationEndpointIcon conversation={conversation} size={20} context="menu-item" />
|
||||
</ConvoLink>
|
||||
)}
|
||||
{isSharedBadgeVisible && (
|
||||
<Link2 className="icon-sm mr-1 shrink-0 text-text-secondary" aria-hidden="true" />
|
||||
)}
|
||||
{conversation.pinned === true && (
|
||||
<Pin className="icon-sm mr-1 shrink-0 text-text-primary" aria-hidden="true" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,26 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { Copy, CopyCheck } from 'lucide-react';
|
||||
import { useGetSharedLinkQuery } from 'librechat-data-provider/react-query';
|
||||
import { OGDialogTemplate, Button, Spinner, OGDialog, Checkbox, Label } from '@librechat/client';
|
||||
import {
|
||||
ESide,
|
||||
Label,
|
||||
Switch,
|
||||
Spinner,
|
||||
OGDialog,
|
||||
InfoHoverCard,
|
||||
OGDialogTitle,
|
||||
OGDialogHeader,
|
||||
OGDialogContent,
|
||||
OGDialogDescription,
|
||||
} from '@librechat/client';
|
||||
import { useLatestMessageId } from '~/hooks/Messages/useLatestMessage';
|
||||
import { useLocalize, useCopyToClipboard } from '~/hooks';
|
||||
import SharedLinkCopyButton from './SharedLinkCopyButton';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import SharedLinkButton from './SharedLinkButton';
|
||||
import { buildShareLinkUrl, cn } from '~/utils';
|
||||
import { buildShareLinkUrl } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
export default function ShareButton({
|
||||
conversationId,
|
||||
|
|
@ -25,36 +38,35 @@ export default function ShareButton({
|
|||
const localize = useLocalize();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const canSnapshotFiles = startupConfig?.sharedLinksSnapshotFilesEnabled === true;
|
||||
const [showQR, setShowQR] = useState(false);
|
||||
const [showQR, setShowQR] = useState(true);
|
||||
const [sharedLink, setSharedLink] = useState('');
|
||||
const [snapshotFiles, setSnapshotFiles] = useState(true);
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState('');
|
||||
const copyLink = useCopyToClipboard({ text: sharedLink });
|
||||
const copyLinkAndAnnounce = (setIsCopying: React.Dispatch<React.SetStateAction<boolean>>) => {
|
||||
setAnnouncement(localize('com_ui_link_copied'));
|
||||
copyLink(setIsCopying);
|
||||
setTimeout(() => {
|
||||
setAnnouncement('');
|
||||
}, 1000);
|
||||
};
|
||||
const latestMessageId = useLatestMessageId(0);
|
||||
const shareFilesSwitchRef = React.useRef<HTMLButtonElement>(null);
|
||||
const activeConversationId = useRecoilValue(store.conversationIdByIndex(0));
|
||||
const activeLatestMessageId = useLatestMessageId(0);
|
||||
/** `useLatestMessageId` resolves the active pane's branch tail, so it only describes
|
||||
* this dialog's conversation when the two match. Sharing another conversation from
|
||||
* the list sends no target, which shares it in full instead of a foreign message. */
|
||||
const latestMessageId = activeConversationId === conversationId ? activeLatestMessageId : null;
|
||||
const { data: share, isLoading } = useGetSharedLinkQuery(conversationId);
|
||||
const shareId = share?.shareId ?? '';
|
||||
|
||||
// Keyed on the conversation too: this dialog outlives a switch between conversations,
|
||||
// so a link built for the previous one must not stay in the copy field.
|
||||
useEffect(() => {
|
||||
if (shareId) {
|
||||
setSharedLink(buildShareLinkUrl(shareId));
|
||||
}
|
||||
}, [shareId]);
|
||||
setSharedLink(shareId ? buildShareLinkUrl(shareId) : '');
|
||||
}, [conversationId, shareId]);
|
||||
|
||||
// Reflect an existing link's stored "share files" choice so the checkbox isn't
|
||||
// misleading (legacy links have no stored choice → keep the default of enabled).
|
||||
// Reflect an existing link's stored "share files" choice so the control isn't
|
||||
// misleading, and fall back to the enabled default for a conversation with no link
|
||||
// or a legacy link that stored no choice, rather than inheriting the last one.
|
||||
useEffect(() => {
|
||||
if (share?.success === true && typeof share.snapshotFiles === 'boolean') {
|
||||
setSnapshotFiles(share.snapshotFiles);
|
||||
}
|
||||
}, [share?.success, share?.snapshotFiles]);
|
||||
setSnapshotFiles(
|
||||
share?.success === true && typeof share.snapshotFiles === 'boolean'
|
||||
? share.snapshotFiles
|
||||
: true,
|
||||
);
|
||||
}, [conversationId, share?.success, share?.snapshotFiles]);
|
||||
|
||||
const button =
|
||||
isLoading === true ? null : (
|
||||
|
|
@ -64,6 +76,7 @@ export default function ShareButton({
|
|||
targetMessageId={latestMessageId ?? undefined}
|
||||
showQR={showQR}
|
||||
setShowQR={setShowQR}
|
||||
sharedLink={sharedLink}
|
||||
setSharedLink={setSharedLink}
|
||||
snapshotFiles={canSnapshotFiles ? snapshotFiles : undefined}
|
||||
/>
|
||||
|
|
@ -72,100 +85,93 @@ export default function ShareButton({
|
|||
return (
|
||||
<OGDialog open={open} onOpenChange={onOpenChange} triggerRef={triggerRef}>
|
||||
{children}
|
||||
<OGDialogTemplate
|
||||
buttons={button}
|
||||
showCloseButton={true}
|
||||
showCancelButton={false}
|
||||
title={localize('com_ui_share_link_to_chat')}
|
||||
className="max-h-[90vh] max-w-[550px] overflow-y-auto"
|
||||
main={
|
||||
<div id="share-conversation-dialog">
|
||||
<div className="h-full py-2 text-text-primary">
|
||||
{(() => {
|
||||
if (isLoading === true) {
|
||||
return <Spinner className="m-auto h-14 animate-spin" />;
|
||||
}
|
||||
|
||||
return share?.success === true
|
||||
<OGDialogContent
|
||||
className="flex max-h-[90vh] w-11/12 max-w-md flex-col gap-0 overflow-hidden p-0 shadow-2xl"
|
||||
onOpenAutoFocus={(event) => {
|
||||
if (shareFilesSwitchRef.current) {
|
||||
event.preventDefault();
|
||||
shareFilesSwitchRef.current.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<OGDialogHeader className="shrink-0 px-6 pb-0 pr-14 pt-6 text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<OGDialogTitle className="text-xl font-semibold tracking-tight">
|
||||
{localize('com_ui_share_link_to_chat')}
|
||||
</OGDialogTitle>
|
||||
<InfoHoverCard
|
||||
icon="info"
|
||||
side={ESide.Bottom}
|
||||
text={
|
||||
share?.success === true
|
||||
? localize('com_ui_share_update_message')
|
||||
: localize('com_ui_share_create_message');
|
||||
})()}
|
||||
</div>
|
||||
{canSnapshotFiles && isLoading !== true && (
|
||||
<div className="flex items-start gap-3 px-2 py-2">
|
||||
<Checkbox
|
||||
id="share-files-checkbox"
|
||||
checked={snapshotFiles}
|
||||
onCheckedChange={(checked) => setSnapshotFiles(checked === true)}
|
||||
aria-label={localize('com_ui_share_files')}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
: localize('com_ui_share_create_message')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<OGDialogDescription className="sr-only">
|
||||
{share?.success === true
|
||||
? localize('com_ui_share_update_message')
|
||||
: localize('com_ui_share_create_message')}
|
||||
</OGDialogDescription>
|
||||
</OGDialogHeader>
|
||||
|
||||
{isLoading === true ? (
|
||||
<div className="flex min-h-72 items-center justify-center px-6 pb-6">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
id="share-conversation-dialog"
|
||||
className="min-h-0 flex-1 space-y-5 overflow-y-auto px-6 pb-6 pt-6"
|
||||
>
|
||||
{canSnapshotFiles && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Label
|
||||
htmlFor="share-files-checkbox"
|
||||
id="share-files-label"
|
||||
htmlFor="share-files-switch"
|
||||
className="cursor-pointer text-sm font-medium text-text-primary"
|
||||
>
|
||||
{localize('com_ui_share_files')}
|
||||
</Label>
|
||||
<span className="text-xs text-text-secondary">
|
||||
{localize('com_ui_share_files_description')}
|
||||
</span>
|
||||
{shareId && (
|
||||
<span className="text-xs font-medium text-text-secondary">
|
||||
{localize('com_ui_share_files_refresh_note')}
|
||||
</span>
|
||||
)}
|
||||
<InfoHoverCard
|
||||
icon="info"
|
||||
side={ESide.Bottom}
|
||||
text={`${localize('com_ui_share_files_description')}${
|
||||
shareId ? ` ${localize('com_ui_share_files_update_note')}` : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
ref={shareFilesSwitchRef}
|
||||
id="share-files-switch"
|
||||
checked={snapshotFiles}
|
||||
onCheckedChange={setSnapshotFiles}
|
||||
aria-labelledby="share-files-label"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative items-center overflow-auto rounded-lg p-2">
|
||||
{showQR && (
|
||||
<div className="mb-4 flex flex-col items-center">
|
||||
|
||||
{showQR && shareId && (
|
||||
<div className="flex min-h-56 items-center justify-center py-1">
|
||||
<div className="rounded-2xl bg-surface-qr p-3 shadow-sm">
|
||||
<QRCodeSVG
|
||||
value={sharedLink}
|
||||
size={200}
|
||||
marginSize={2}
|
||||
className="rounded-2xl"
|
||||
marginSize={1}
|
||||
title={localize('com_ui_share_qr_code_description')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shareId && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-surface-secondary p-2">
|
||||
<div
|
||||
className="flex-1 break-all text-sm text-text-secondary"
|
||||
data-testid="shared-link-url"
|
||||
>
|
||||
{sharedLink}
|
||||
</div>
|
||||
<span className="sr-only" aria-live="polite" aria-atomic="true">
|
||||
{announcement}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label={localize('com_ui_copy_link')}
|
||||
onClick={() => {
|
||||
if (isCopying) {
|
||||
return;
|
||||
}
|
||||
copyLinkAndAnnounce(setIsCopying);
|
||||
}}
|
||||
className={cn('shrink-0', isCopying ? 'cursor-default' : '')}
|
||||
>
|
||||
{isCopying ? (
|
||||
<CopyCheck className="size-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Copy className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{shareId && <SharedLinkCopyButton sharedLink={sharedLink} />}
|
||||
|
||||
<div className="pt-1">{button}</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useRef } from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import { QrCode, RotateCw, Trash2 } from 'lucide-react';
|
||||
import { Link2Off, QrCode, RotateCw, Share, Users } from 'lucide-react';
|
||||
import {
|
||||
PermissionTypes,
|
||||
Permissions,
|
||||
|
|
@ -13,9 +13,11 @@ import {
|
|||
Spinner,
|
||||
OGDialog,
|
||||
OGDialogClose,
|
||||
OGDialogDescription,
|
||||
TooltipAnchor,
|
||||
OGDialogTitle,
|
||||
OGDialogHeader,
|
||||
useMediaQuery,
|
||||
useToastContext,
|
||||
OGDialogContent,
|
||||
} from '@librechat/client';
|
||||
|
|
@ -36,6 +38,7 @@ export default function SharedLinkButton({
|
|||
targetMessageId,
|
||||
showQR,
|
||||
setShowQR,
|
||||
sharedLink,
|
||||
setSharedLink,
|
||||
snapshotFiles,
|
||||
}: {
|
||||
|
|
@ -44,15 +47,25 @@ export default function SharedLinkButton({
|
|||
targetMessageId?: string;
|
||||
showQR: boolean;
|
||||
setShowQR: (showQR: boolean) => void;
|
||||
sharedLink: string;
|
||||
setSharedLink: (sharedLink: string) => void;
|
||||
snapshotFiles?: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const deleteButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const updateButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showUpdateDialog, setShowUpdateDialog] = useState(false);
|
||||
const [refreshAnimationId, setRefreshAnimationId] = useState(0);
|
||||
const [canNativeShare, setCanNativeShare] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState('');
|
||||
const shareId = share?.shareId ?? '';
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
useEffect(() => {
|
||||
setCanNativeShare(typeof navigator !== 'undefined' && typeof navigator.share === 'function');
|
||||
}, []);
|
||||
|
||||
const { mutateAsync: mutate, isLoading: isCreateLoading } = useCreateSharedLinkMutation({
|
||||
onError: () => {
|
||||
|
|
@ -101,19 +114,28 @@ export default function SharedLinkButton({
|
|||
if (!shareId) {
|
||||
return;
|
||||
}
|
||||
const updateShare = await mutateAsync({ shareId, targetMessageId, snapshotFiles });
|
||||
const newLink = generateShareLink(updateShare.shareId);
|
||||
setSharedLink(newLink);
|
||||
setAnnouncement(localize('com_ui_link_refreshed'));
|
||||
setTimeout(() => {
|
||||
setAnnouncement('');
|
||||
}, 1000);
|
||||
|
||||
try {
|
||||
const updateShare = await mutateAsync({ shareId, targetMessageId, snapshotFiles });
|
||||
setRefreshAnimationId((animationId) => animationId + 1);
|
||||
setSharedLink(generateShareLink(updateShare.shareId));
|
||||
setShowUpdateDialog(false);
|
||||
setAnnouncement(localize('com_ui_link_refreshed'));
|
||||
setTimeout(() => {
|
||||
setAnnouncement('');
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Failed to update shared link:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const createShareLink = async () => {
|
||||
const share = await mutate({ conversationId, targetMessageId, snapshotFiles });
|
||||
const newLink = generateShareLink(share.shareId);
|
||||
setSharedLink(newLink);
|
||||
try {
|
||||
const share = await mutate({ conversationId, targetMessageId, snapshotFiles });
|
||||
setSharedLink(generateShareLink(share.shareId));
|
||||
} catch (error) {
|
||||
console.error('Failed to create shared link:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
|
|
@ -123,6 +145,8 @@ export default function SharedLinkButton({
|
|||
|
||||
try {
|
||||
await deleteMutation.mutateAsync({ shareId });
|
||||
setShowDeleteDialog(false);
|
||||
setSharedLink('');
|
||||
showToast({
|
||||
message: localize('com_ui_shared_link_delete_success'),
|
||||
severity: NotificationSeverity.SUCCESS,
|
||||
|
|
@ -136,6 +160,29 @@ export default function SharedLinkButton({
|
|||
}
|
||||
};
|
||||
|
||||
const handleNativeShare = async () => {
|
||||
if (!canNativeShare || !sharedLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.share({
|
||||
title: localize('com_ui_share_link_to_chat'),
|
||||
url: sharedLink,
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as Error).name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
|
||||
showToast({
|
||||
message: localize('com_ui_share_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
showIcon: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const hasAccessToShareLinks = useHasAccess({
|
||||
permissionType: PermissionTypes.SHARED_LINKS,
|
||||
permission: Permissions.SHARE,
|
||||
|
|
@ -156,68 +203,21 @@ export default function SharedLinkButton({
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex w-full flex-wrap items-center gap-2">
|
||||
{!shareId && (
|
||||
<Button disabled={isCreateLoading} variant="submit" onClick={createShareLink}>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isCreateLoading}
|
||||
variant="submit"
|
||||
onClick={createShareLink}
|
||||
className="ml-auto min-w-28"
|
||||
>
|
||||
{!isCreateLoading && localize('com_ui_create_link')}
|
||||
{isCreateLoading && <Spinner className="size-4" />}
|
||||
</Button>
|
||||
)}
|
||||
{shareId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_refresh_link')}
|
||||
render={(props) => (
|
||||
<>
|
||||
<span className="sr-only" aria-live="polite" aria-atomic="true">
|
||||
{announcement}
|
||||
</span>
|
||||
<Button
|
||||
{...props}
|
||||
onClick={() => updateSharedLink()}
|
||||
variant="outline"
|
||||
disabled={isUpdateLoading}
|
||||
aria-label={localize('com_ui_refresh_link')}
|
||||
>
|
||||
{isUpdateLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<RotateCw className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<TooltipAnchor
|
||||
description={qrCodeLabel}
|
||||
render={(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
onClick={() => setShowQR(!showQR)}
|
||||
variant="outline"
|
||||
aria-label={qrCodeLabel}
|
||||
>
|
||||
<QrCode className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_delete')}
|
||||
render={(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
ref={deleteButtonRef}
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
variant="destructive"
|
||||
aria-label={localize('com_ui_delete')}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<>
|
||||
{canManageAccess && (
|
||||
<GenericGrantAccessDialog
|
||||
resourceType={ResourceType.SHARED_LINK}
|
||||
|
|
@ -229,23 +229,147 @@ export default function SharedLinkButton({
|
|||
render={(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="size-9 sm:size-10"
|
||||
aria-label={localize('com_ui_shared_link_manage_access')}
|
||||
>
|
||||
{localize('com_ui_shared_link_manage_access')}
|
||||
<Users className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</GenericGrantAccessDialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex flex-wrap items-center justify-end gap-1.5 sm:gap-2">
|
||||
{isSmallScreen && canNativeShare && (
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_share')}
|
||||
render={(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
type="button"
|
||||
onClick={handleNativeShare}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-9 sm:size-10"
|
||||
aria-label={localize('com_ui_share')}
|
||||
>
|
||||
<Share className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span className="sr-only" aria-live="polite" aria-atomic="true">
|
||||
{announcement}
|
||||
</span>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_update_shared_link')}
|
||||
render={(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
ref={updateButtonRef}
|
||||
type="button"
|
||||
onClick={() => setShowUpdateDialog(true)}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-9 sm:size-10"
|
||||
disabled={isUpdateLoading}
|
||||
aria-label={localize('com_ui_update_shared_link')}
|
||||
>
|
||||
<RotateCw
|
||||
key={refreshAnimationId}
|
||||
className={
|
||||
refreshAnimationId > 0
|
||||
? 'size-4 animate-refresh-link-spin motion-reduce:animate-none'
|
||||
: 'size-4'
|
||||
}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<TooltipAnchor
|
||||
description={qrCodeLabel}
|
||||
render={(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
type="button"
|
||||
onClick={() => setShowQR(!showQR)}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-9 sm:size-10"
|
||||
aria-pressed={showQR}
|
||||
aria-label={qrCodeLabel}
|
||||
>
|
||||
<QrCode className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_delete_link')}
|
||||
render={(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
ref={deleteButtonRef}
|
||||
type="button"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="size-9 sm:size-10"
|
||||
aria-label={localize('com_ui_delete_link')}
|
||||
>
|
||||
<Link2Off className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<OGDialog
|
||||
open={showUpdateDialog}
|
||||
triggerRef={updateButtonRef}
|
||||
onOpenChange={setShowUpdateDialog}
|
||||
>
|
||||
<OGDialogContent className="w-11/12 max-w-md" showCloseButton={false}>
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>{localize('com_ui_update_shared_link_confirm_title')}</OGDialogTitle>
|
||||
<OGDialogDescription className="text-text-secondary">
|
||||
{localize('com_ui_update_shared_link_confirm_description')}
|
||||
</OGDialogDescription>
|
||||
</OGDialogHeader>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<OGDialogClose asChild>
|
||||
<Button variant="outline">{localize('com_ui_cancel')}</Button>
|
||||
</OGDialogClose>
|
||||
<Button
|
||||
type="button"
|
||||
variant="submit"
|
||||
onClick={updateSharedLink}
|
||||
disabled={isUpdateLoading}
|
||||
>
|
||||
{isUpdateLoading && <Spinner className="size-4" />}
|
||||
{localize('com_ui_update_shared_link')}
|
||||
</Button>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
|
||||
<OGDialog
|
||||
open={showDeleteDialog}
|
||||
triggerRef={deleteButtonRef}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
>
|
||||
<OGDialogContent className="max-w-[450px]" showCloseButton={false}>
|
||||
<OGDialogContent
|
||||
role="alertdialog"
|
||||
className="w-11/12 max-w-[450px]"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>{localize('com_ui_delete_shared_link_heading')}</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
|
|
@ -272,7 +396,7 @@ export default function SharedLinkButton({
|
|||
{deleteMutation.isLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
localize('com_ui_delete')
|
||||
localize('com_ui_delete_link')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import { useState } from 'react';
|
||||
import { Input } from '@librechat/client';
|
||||
import CopyButton from '~/components/Messages/Content/CopyButton';
|
||||
import { useCopyToClipboard, useLocalize } from '~/hooks';
|
||||
import { useLiveAnnouncer } from '~/Providers';
|
||||
|
||||
export default function SharedLinkCopyButton({ sharedLink }: { sharedLink: string }) {
|
||||
const localize = useLocalize();
|
||||
const { announcePolite } = useLiveAnnouncer();
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const copyLink = useCopyToClipboard({ text: sharedLink });
|
||||
|
||||
const handleCopy = () => {
|
||||
if (isCopied) {
|
||||
return;
|
||||
}
|
||||
|
||||
copyLink(setIsCopied);
|
||||
announcePolite({ message: localize('com_ui_link_copied'), isStatus: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
readOnly
|
||||
dir="ltr"
|
||||
value={sharedLink}
|
||||
aria-label={localize('com_ui_shared_link')}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
className="h-11 rounded-xl bg-surface-secondary pr-12 text-right text-sm text-text-primary"
|
||||
data-testid="shared-link-url"
|
||||
/>
|
||||
<CopyButton
|
||||
iconOnly
|
||||
isCopied={isCopied}
|
||||
label={localize('com_ui_copy_link')}
|
||||
copiedLabel={localize('com_ui_copied')}
|
||||
onClick={handleCopy}
|
||||
className="absolute right-1.5 top-1/2 size-8 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
import React from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import type { MutableSnapshot } from 'recoil';
|
||||
import ShareButton from '../ShareButton';
|
||||
import store from '~/store';
|
||||
|
||||
let mockShare: {
|
||||
success: boolean;
|
||||
shareId: string | null;
|
||||
snapshotFiles?: boolean;
|
||||
} = {
|
||||
success: true,
|
||||
shareId: 'share-1',
|
||||
snapshotFiles: true,
|
||||
};
|
||||
const mockCopyLink = jest.fn();
|
||||
const mockAnnouncePolite = jest.fn();
|
||||
|
||||
jest.mock('librechat-data-provider/react-query', () => ({
|
||||
useGetSharedLinkQuery: () => ({ data: mockShare, isLoading: false }),
|
||||
}));
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetStartupConfig: () => ({ data: { sharedLinksSnapshotFilesEnabled: true } }),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/Messages/useLatestMessage', () => ({
|
||||
useLatestMessageId: () => 'message-1',
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
useCopyToClipboard: () => mockCopyLink,
|
||||
}));
|
||||
|
||||
jest.mock('~/Providers', () => ({
|
||||
useLiveAnnouncer: () => ({ announcePolite: mockAnnouncePolite }),
|
||||
}));
|
||||
|
||||
jest.mock('../SharedLinkButton', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
showQR,
|
||||
setShowQR,
|
||||
snapshotFiles,
|
||||
targetMessageId,
|
||||
}: {
|
||||
showQR: boolean;
|
||||
setShowQR: (show: boolean) => void;
|
||||
snapshotFiles?: boolean;
|
||||
targetMessageId?: string;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="share-actions"
|
||||
data-snapshot-files={String(snapshotFiles)}
|
||||
data-target-message-id={String(targetMessageId)}
|
||||
onClick={() => setShowQR(!showQR)}
|
||||
>
|
||||
{showQR ? 'com_ui_hide_qr' : 'com_ui_show_qr'}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const ACTIVE_CONVERSATION_ID = 'conversation-1';
|
||||
|
||||
const renderShareButton = (conversationId = ACTIVE_CONVERSATION_ID) => {
|
||||
const initializeState = ({ set }: MutableSnapshot) => {
|
||||
set(store.conversationByIndex(0), {
|
||||
conversationId: ACTIVE_CONVERSATION_ID,
|
||||
} as never);
|
||||
};
|
||||
|
||||
return render(
|
||||
<RecoilRoot initializeState={initializeState}>
|
||||
<ShareButton conversationId={conversationId} open={true} onOpenChange={jest.fn()} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('ShareButton', () => {
|
||||
beforeEach(() => {
|
||||
mockShare = {
|
||||
success: true,
|
||||
shareId: 'share-1',
|
||||
snapshotFiles: true,
|
||||
};
|
||||
mockCopyLink.mockClear();
|
||||
mockAnnouncePolite.mockClear();
|
||||
});
|
||||
|
||||
it('centers the active QR code and keeps details behind inline info controls', () => {
|
||||
renderShareButton();
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'com_ui_share_link_to_chat' })).toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_share_update_message')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('com_ui_share_update_message')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/com_ui_share_files_description/)).toBeInTheDocument();
|
||||
const sharedLinkInput = screen.getByTestId('shared-link-url') as HTMLInputElement;
|
||||
expect(sharedLinkInput.value).toContain('/share/share-1');
|
||||
expect(sharedLinkInput).toHaveClass('text-right');
|
||||
expect(sharedLinkInput).toHaveAttribute('dir', 'ltr');
|
||||
expect(screen.getByRole('button', { name: 'com_ui_copy_link' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('switch', { name: 'com_ui_share_files' })).toBeChecked();
|
||||
expect(document.querySelector('svg[width="200"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lets the action bar hide and restore the QR code', () => {
|
||||
renderShareButton();
|
||||
|
||||
fireEvent.click(screen.getByTestId('share-actions'));
|
||||
expect(document.querySelector('svg[width="200"]')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('share-actions'));
|
||||
expect(document.querySelector('svg[width="200"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copies the shared URL through the reusable copy control', () => {
|
||||
renderShareButton();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_copy_link' }));
|
||||
|
||||
expect(mockCopyLink).toHaveBeenCalledTimes(1);
|
||||
expect(mockAnnouncePolite).toHaveBeenCalledWith({
|
||||
message: 'com_ui_link_copied',
|
||||
isStatus: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes file-sharing changes to the link actions', () => {
|
||||
renderShareButton();
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'com_ui_share_files' }));
|
||||
|
||||
expect(screen.getByTestId('share-actions')).toHaveAttribute('data-snapshot-files', 'false');
|
||||
});
|
||||
|
||||
it('resets the file choice and link when the dialog moves to another conversation', () => {
|
||||
mockShare = { success: true, shareId: 'share-1', snapshotFiles: false };
|
||||
const { rerender } = renderShareButton();
|
||||
|
||||
expect(screen.getByRole('switch', { name: 'com_ui_share_files' })).not.toBeChecked();
|
||||
|
||||
mockShare = { success: true, shareId: null };
|
||||
rerender(
|
||||
<RecoilRoot
|
||||
initializeState={({ set }: MutableSnapshot) => {
|
||||
set(store.conversationByIndex(0), {
|
||||
conversationId: ACTIVE_CONVERSATION_ID,
|
||||
} as never);
|
||||
}}
|
||||
>
|
||||
<ShareButton conversationId="conversation-2" open={true} onOpenChange={jest.fn()} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('switch', { name: 'com_ui_share_files' })).toBeChecked();
|
||||
const sharedLinkInput = screen.queryByTestId('shared-link-url') as HTMLInputElement | null;
|
||||
expect(sharedLinkInput?.value ?? '').not.toContain('/share/share-1');
|
||||
});
|
||||
|
||||
it('targets the active branch tail when sharing the open conversation', () => {
|
||||
renderShareButton();
|
||||
|
||||
expect(screen.getByTestId('share-actions')).toHaveAttribute(
|
||||
'data-target-message-id',
|
||||
'message-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends no target message when sharing a conversation other than the open one', () => {
|
||||
renderShareButton('conversation-2');
|
||||
|
||||
expect(screen.getByTestId('share-actions')).toHaveAttribute(
|
||||
'data-target-message-id',
|
||||
'undefined',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the compact create state without an empty link field', () => {
|
||||
mockShare = { success: false, shareId: null };
|
||||
|
||||
renderShareButton();
|
||||
|
||||
expect(screen.getByText('com_ui_share_create_message')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('shared-link-url')).not.toBeInTheDocument();
|
||||
expect(document.querySelector('svg[width="200"]')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import SharedLinkButton from '../SharedLinkButton';
|
||||
|
||||
const mockCreate = jest.fn();
|
||||
const mockUpdate = jest.fn();
|
||||
const mockDelete = jest.fn();
|
||||
const mockShowToast = jest.fn();
|
||||
let mockIsSmallScreen = false;
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
Trans: ({ i18nKey }: { i18nKey: string }) => <>{i18nKey}</>,
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => {
|
||||
const actual = jest.requireActual('@librechat/client');
|
||||
return {
|
||||
...actual,
|
||||
useMediaQuery: () => mockIsSmallScreen,
|
||||
useToastContext: () => ({ showToast: mockShowToast }),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useCreateSharedLinkMutation: () => ({ mutateAsync: mockCreate, isLoading: false }),
|
||||
useUpdateSharedLinkMutation: () => ({ mutateAsync: mockUpdate, isLoading: false }),
|
||||
useDeleteSharedLinkMutation: () => ({
|
||||
mutateAsync: mockDelete,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
useHasAccess: () => true,
|
||||
useResourcePermissions: () => ({
|
||||
hasPermission: () => true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('~/components/Sharing/GenericGrantAccessDialog', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
const share = {
|
||||
success: true,
|
||||
_id: 'share-db-id',
|
||||
shareId: 'share-old',
|
||||
conversationId: 'conversation-1',
|
||||
targetMessageId: 'message-1',
|
||||
};
|
||||
|
||||
const renderActions = (overrides = {}) => {
|
||||
const props = {
|
||||
share,
|
||||
conversationId: 'conversation-1',
|
||||
targetMessageId: 'message-1',
|
||||
showQR: true,
|
||||
setShowQR: jest.fn(),
|
||||
sharedLink: 'http://example.test/share/share-old',
|
||||
setSharedLink: jest.fn(),
|
||||
snapshotFiles: true,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
render(<SharedLinkButton {...props} />);
|
||||
return props;
|
||||
};
|
||||
|
||||
describe('SharedLinkButton', () => {
|
||||
beforeEach(() => {
|
||||
mockIsSmallScreen = false;
|
||||
mockCreate.mockReset();
|
||||
mockUpdate.mockReset();
|
||||
mockDelete.mockReset();
|
||||
mockShowToast.mockReset();
|
||||
Object.defineProperty(navigator, 'share', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a compact update action before QR and link revocation', () => {
|
||||
renderActions();
|
||||
|
||||
const access = screen.getByRole('button', { name: 'com_ui_shared_link_manage_access' });
|
||||
const update = screen.getByRole('button', { name: 'com_ui_update_shared_link' });
|
||||
const qr = screen.getByRole('button', { name: 'com_ui_hide_qr' });
|
||||
const remove = screen.getByRole('button', { name: 'com_ui_delete_link' });
|
||||
|
||||
expect(access).not.toHaveTextContent('com_ui_shared_link_manage_access');
|
||||
expect(update).not.toHaveTextContent('com_ui_update_shared_link');
|
||||
expect(update).toHaveClass('size-9', 'sm:size-10');
|
||||
expect(update.compareDocumentPosition(qr) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(qr.compareDocumentPosition(remove) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(remove).toHaveClass('bg-surface-destructive');
|
||||
|
||||
fireEvent.click(remove);
|
||||
|
||||
const dialog = screen.getByRole('alertdialog');
|
||||
const confirmDelete = within(dialog).getByRole('button', { name: 'com_ui_delete_link' });
|
||||
expect(confirmDelete).toHaveClass('bg-surface-destructive');
|
||||
});
|
||||
|
||||
it('updates the existing URL with the latest conversation state', async () => {
|
||||
mockUpdate.mockResolvedValue({ shareId: 'share-old' });
|
||||
const setSharedLink = jest.fn();
|
||||
renderActions({ setSharedLink });
|
||||
|
||||
const updateButton = screen.getByRole('button', { name: 'com_ui_update_shared_link' });
|
||||
fireEvent.click(updateButton);
|
||||
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(
|
||||
within(dialog).getByRole('heading', {
|
||||
name: 'com_ui_update_shared_link_confirm_title',
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByText('com_ui_update_shared_link_confirm_description'),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'com_ui_update_shared_link' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith({
|
||||
shareId: 'share-old',
|
||||
targetMessageId: 'message-1',
|
||||
snapshotFiles: true,
|
||||
});
|
||||
});
|
||||
expect(updateButton.querySelector('svg')).toHaveClass('animate-refresh-link-spin');
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(setSharedLink).toHaveBeenCalledWith(expect.stringContaining('/share/share-old'));
|
||||
});
|
||||
|
||||
it('does not fake success when updating the link fails', async () => {
|
||||
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockUpdate.mockRejectedValue(new Error('update failed'));
|
||||
const setSharedLink = jest.fn();
|
||||
renderActions({ setSharedLink });
|
||||
|
||||
const updateButton = screen.getByRole('button', { name: 'com_ui_update_shared_link' });
|
||||
fireEvent.click(updateButton);
|
||||
const dialog = screen.getByRole('dialog');
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'com_ui_update_shared_link' }));
|
||||
|
||||
await waitFor(() => expect(mockUpdate).toHaveBeenCalled());
|
||||
|
||||
expect(setSharedLink).not.toHaveBeenCalled();
|
||||
expect(updateButton.querySelector('svg')).not.toHaveClass('animate-refresh-link-spin');
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('closes the delete confirmation and clears the stale URL after deletion', async () => {
|
||||
mockDelete.mockResolvedValue({ success: true, shareId: 'share-old' });
|
||||
const setSharedLink = jest.fn();
|
||||
renderActions({ setSharedLink });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_delete_link' }));
|
||||
const dialog = screen.getByRole('alertdialog');
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'com_ui_delete_link' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDelete).toHaveBeenCalledWith({ shareId: 'share-old' });
|
||||
});
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
|
||||
expect(setSharedLink).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('exposes native sharing only on supported small screens', async () => {
|
||||
const nativeShare = jest.fn().mockResolvedValue(undefined);
|
||||
mockIsSmallScreen = true;
|
||||
Object.defineProperty(navigator, 'share', {
|
||||
configurable: true,
|
||||
value: nativeShare,
|
||||
});
|
||||
renderActions();
|
||||
|
||||
const shareButton = await screen.findByRole('button', { name: 'com_ui_share' });
|
||||
fireEvent.click(shareButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(nativeShare).toHaveBeenCalledWith({
|
||||
title: 'com_ui_share_link_to_chat',
|
||||
url: 'http://example.test/share/share-old',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import type { TConversation } from 'librechat-data-provider';
|
||||
import { areConversationListItemFieldsEqual } from '../utils';
|
||||
|
||||
const baseConversation = {
|
||||
conversationId: 'conversation-1',
|
||||
title: 'Shared chat',
|
||||
endpoint: 'openAI',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
} as unknown as TConversation;
|
||||
|
||||
describe('conversation list memoization of the shared badge', () => {
|
||||
it('treats a change in shared state as a re-render', () => {
|
||||
const shared = { ...baseConversation, isShared: true } as TConversation;
|
||||
const unshared = { ...baseConversation, isShared: false } as TConversation;
|
||||
|
||||
expect(areConversationListItemFieldsEqual(shared, unshared)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a newly shared conversation as a re-render', () => {
|
||||
const shared = { ...baseConversation, isShared: true } as TConversation;
|
||||
|
||||
expect(areConversationListItemFieldsEqual(baseConversation, shared)).toBe(false);
|
||||
});
|
||||
|
||||
it('still memoizes when nothing relevant changed', () => {
|
||||
const first = { ...baseConversation, isShared: true } as TConversation;
|
||||
const second = { ...baseConversation, isShared: true } as TConversation;
|
||||
|
||||
expect(areConversationListItemFieldsEqual(first, second)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -31,6 +31,7 @@ export function areConversationListItemFieldsEqual(
|
|||
prevConversation.conversationId === nextConversation.conversationId &&
|
||||
prevConversation.title === nextConversation.title &&
|
||||
prevConversation.chatProjectId === nextConversation.chatProjectId &&
|
||||
prevConversation.isShared === nextConversation.isShared &&
|
||||
prevConversation.createdAt === nextConversation.createdAt &&
|
||||
prevConversation.updatedAt === nextConversation.updatedAt
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,22 +1,13 @@
|
|||
import { useCallback, useState, useMemo, useEffect } from 'react';
|
||||
import { useCallback, useState, useMemo } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
ArrowUp,
|
||||
TrashIcon,
|
||||
ArrowDown,
|
||||
ArrowUpDown,
|
||||
ExternalLink,
|
||||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
import { TrashIcon, ExternalLink, MessageSquare } from 'lucide-react';
|
||||
import {
|
||||
Label,
|
||||
Button,
|
||||
Spinner,
|
||||
OGDialog,
|
||||
DataTable,
|
||||
useMediaQuery,
|
||||
OGDialogTitle,
|
||||
TooltipAnchor,
|
||||
|
|
@ -25,9 +16,11 @@ import {
|
|||
OGDialogContent,
|
||||
useToastContext,
|
||||
OGDialogTemplate,
|
||||
VirtualizedDataTable,
|
||||
} from '@librechat/client';
|
||||
import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider';
|
||||
import type { TranslationKeys } from '~/hooks';
|
||||
import type { SortingState, Updater } from '@tanstack/react-table';
|
||||
import type { TableColumn } from '@librechat/client';
|
||||
import { useDeleteSharedLinkMutation, useSharedLinksQuery } from '~/data-provider';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
|
@ -43,6 +36,8 @@ const DEFAULT_PARAMS: SharedLinksListParams = {
|
|||
search: '',
|
||||
};
|
||||
|
||||
type SharedLinkRow = SharedLinkItem & Record<string, unknown>;
|
||||
|
||||
export default function SharedLinks() {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
|
|
@ -53,7 +48,7 @@ export default function SharedLinks() {
|
|||
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
|
||||
const [queryParams, setQueryParams] = useState<SharedLinksListParams>(DEFAULT_PARAMS);
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isLoading } =
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isLoading, isFetching } =
|
||||
useSharedLinksQuery(queryParams, {
|
||||
enabled: isOpen,
|
||||
staleTime: 0,
|
||||
|
|
@ -63,25 +58,13 @@ export default function SharedLinks() {
|
|||
});
|
||||
|
||||
const handleFilterChange = useCallback((value: string) => {
|
||||
const encodedValue = encodeURIComponent(value.trim());
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
search: encodedValue,
|
||||
search: value.trim(),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const debouncedFilterChange = useMemo(
|
||||
() => debounce(handleFilterChange, 300),
|
||||
[handleFilterChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedFilterChange.cancel();
|
||||
};
|
||||
}, [debouncedFilterChange]);
|
||||
|
||||
const allLinks = useMemo(() => {
|
||||
const allLinks = useMemo<SharedLinkRow[]>(() => {
|
||||
if (!data?.pages) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -89,6 +72,40 @@ export default function SharedLinks() {
|
|||
return data.pages.flatMap((page) => page.links.filter(Boolean));
|
||||
}, [data?.pages]);
|
||||
|
||||
const sorting = useMemo<SortingState>(
|
||||
() => [
|
||||
{
|
||||
id: queryParams.sortBy,
|
||||
desc: queryParams.sortDirection === 'desc',
|
||||
},
|
||||
],
|
||||
[queryParams.sortBy, queryParams.sortDirection],
|
||||
);
|
||||
|
||||
const handleSortingChange = useCallback((updater: Updater<SortingState>) => {
|
||||
setQueryParams((prev) => {
|
||||
const currentSorting: SortingState = [
|
||||
{ id: prev.sortBy, desc: prev.sortDirection === 'desc' },
|
||||
];
|
||||
const nextSorting = typeof updater === 'function' ? updater(currentSorting) : updater;
|
||||
const nextSort = nextSorting[0];
|
||||
|
||||
if (nextSort?.id !== 'title' && nextSort?.id !== 'createdAt') {
|
||||
return {
|
||||
...prev,
|
||||
sortBy: DEFAULT_PARAMS.sortBy,
|
||||
sortDirection: DEFAULT_PARAMS.sortDirection,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
sortBy: nextSort.id,
|
||||
sortDirection: nextSort.desc ? 'desc' : 'asc',
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const deleteMutation = useDeleteSharedLinkMutation({
|
||||
onSuccess: async () => {
|
||||
setIsDeleteOpen(false);
|
||||
|
|
@ -112,7 +129,7 @@ export default function SharedLinks() {
|
|||
|
||||
if (validRows.length === 0) {
|
||||
showToast({
|
||||
message: localize('com_ui_no_valid_items' as TranslationKeys),
|
||||
message: localize('com_ui_no_valid_items'),
|
||||
severity: NotificationSeverity.WARNING,
|
||||
});
|
||||
return;
|
||||
|
|
@ -126,15 +143,15 @@ export default function SharedLinks() {
|
|||
showToast({
|
||||
message: localize(
|
||||
validRows.length === 1
|
||||
? ('com_ui_shared_link_delete_success' as TranslationKeys)
|
||||
: ('com_ui_shared_link_bulk_delete_success' as TranslationKeys),
|
||||
? 'com_ui_shared_link_delete_success'
|
||||
: 'com_ui_shared_link_bulk_delete_success',
|
||||
),
|
||||
severity: NotificationSeverity.SUCCESS,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to delete shared links:', error);
|
||||
showToast({
|
||||
message: localize('com_ui_bulk_delete_error' as TranslationKeys),
|
||||
message: localize('com_ui_bulk_delete_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
}
|
||||
|
|
@ -149,48 +166,17 @@ export default function SharedLinks() {
|
|||
await fetchNextPage();
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
const confirmDelete = useCallback(() => {
|
||||
const confirmDelete = useCallback(async () => {
|
||||
if (deleteRow) {
|
||||
handleDelete([deleteRow]);
|
||||
await handleDelete([deleteRow]);
|
||||
}
|
||||
setIsDeleteOpen(false);
|
||||
}, [deleteRow, handleDelete]);
|
||||
|
||||
const columns = useMemo(
|
||||
const columns = useMemo<TableColumn<SharedLinkRow, unknown>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: ({ column }) => {
|
||||
const sortState = column.getIsSorted();
|
||||
let SortIcon = ArrowUpDown;
|
||||
let ariaSort: 'ascending' | 'descending' | 'none' = 'none';
|
||||
if (sortState === 'desc') {
|
||||
SortIcon = ArrowDown;
|
||||
ariaSort = 'descending';
|
||||
} else if (sortState === 'asc') {
|
||||
SortIcon = ArrowUp;
|
||||
ariaSort = 'ascending';
|
||||
}
|
||||
return (
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_name_sort')}
|
||||
side="top"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||
aria-sort={ariaSort}
|
||||
aria-label={localize('com_ui_name_sort')}
|
||||
aria-current={sortState ? 'true' : 'false'}
|
||||
>
|
||||
{localize('com_ui_name')}
|
||||
<SortIcon className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
header: localize('com_ui_name'),
|
||||
cell: ({ row }) => {
|
||||
const { title, shareId } = row.original;
|
||||
const link = (
|
||||
|
|
@ -214,59 +200,25 @@ export default function SharedLinks() {
|
|||
);
|
||||
},
|
||||
meta: {
|
||||
size: '32%',
|
||||
mobileSize: '50%',
|
||||
width: 55,
|
||||
isRowHeader: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: ({ column }) => {
|
||||
const sortState = column.getIsSorted();
|
||||
let SortIcon = ArrowUpDown;
|
||||
let ariaSort: 'ascending' | 'descending' | 'none' = 'none';
|
||||
if (sortState === 'desc') {
|
||||
SortIcon = ArrowDown;
|
||||
ariaSort = 'descending';
|
||||
} else if (sortState === 'asc') {
|
||||
SortIcon = ArrowUp;
|
||||
ariaSort = 'ascending';
|
||||
}
|
||||
return (
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_date_sort')}
|
||||
side="top"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||
aria-sort={ariaSort}
|
||||
aria-label={localize('com_ui_date_sort')}
|
||||
aria-current={sortState ? 'true' : 'false'}
|
||||
>
|
||||
{localize('com_ui_date')}
|
||||
<SortIcon className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
header: localize('com_ui_date'),
|
||||
cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen),
|
||||
meta: {
|
||||
size: '10%',
|
||||
mobileSize: '20%',
|
||||
width: 25,
|
||||
desktopOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'actions',
|
||||
header: () => (
|
||||
<Label className="px-2 py-0 text-xs sm:px-2 sm:py-2 sm:text-sm">
|
||||
{localize('com_assistants_actions')}
|
||||
</Label>
|
||||
),
|
||||
id: 'actions',
|
||||
header: localize('com_assistants_actions'),
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
size: '7%',
|
||||
mobileSize: '25%',
|
||||
width: 20,
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -332,20 +284,24 @@ export default function SharedLinks() {
|
|||
<OGDialogHeader>
|
||||
<OGDialogTitle>{localize('com_nav_shared_links')}</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<DataTable
|
||||
<VirtualizedDataTable
|
||||
columns={columns}
|
||||
data={allLinks}
|
||||
className="scrollbar-gutter-stable"
|
||||
onDelete={handleDelete}
|
||||
filterColumn="title"
|
||||
getRowId={(row) => row.shareId}
|
||||
className="scrollbar-gutter-stable h-[60vh]"
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isFetching={isFetching}
|
||||
fetchNextPage={handleFetchNextPage}
|
||||
showCheckboxes={false}
|
||||
onFilterChange={debouncedFilterChange}
|
||||
sorting={sorting}
|
||||
onSortingChange={handleSortingChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
filterValue={queryParams.search}
|
||||
isLoading={isLoading}
|
||||
enableSearch={searchStore.enabled === true}
|
||||
config={{
|
||||
selection: { enableRowSelection: false, showCheckboxes: false },
|
||||
search: { enableSearch: searchStore.enabled === true, debounce: 300 },
|
||||
}}
|
||||
/>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export function ArchivedChatsModal({
|
|||
<OGDialogHeader>
|
||||
<OGDialogTitle>{localize('com_nav_archived_chats')}</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<ArchivedChatsTable onOpenChange={onOpenChange} />
|
||||
<ArchivedChatsTable />
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,30 +1,23 @@
|
|||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { TrashIcon, ExternalLink, ArchiveRestore } from 'lucide-react';
|
||||
import {
|
||||
ArrowUp,
|
||||
TrashIcon,
|
||||
ArrowDown,
|
||||
ArrowUpDown,
|
||||
ExternalLink,
|
||||
ArchiveRestore,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Label,
|
||||
Button,
|
||||
Spinner,
|
||||
OGDialog,
|
||||
DataTable,
|
||||
TooltipAnchor,
|
||||
useMediaQuery,
|
||||
OGDialogTitle,
|
||||
OGDialogHeader,
|
||||
useToastContext,
|
||||
OGDialogContent,
|
||||
VirtualizedDataTable,
|
||||
} from '@librechat/client';
|
||||
import type { ConversationListParams, TConversation } from 'librechat-data-provider';
|
||||
import type { SortingState, Updater } from '@tanstack/react-table';
|
||||
import type { TableColumn } from '@librechat/client';
|
||||
import {
|
||||
useConversationsInfiniteQuery,
|
||||
useDeleteConversationMutation,
|
||||
|
|
@ -43,11 +36,9 @@ const DEFAULT_PARAMS: ConversationListParams = {
|
|||
search: '',
|
||||
};
|
||||
|
||||
export default function ArchivedChatsTable({
|
||||
onOpenChange,
|
||||
}: {
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
}) {
|
||||
type ArchivedConversationRow = TConversation & Record<string, unknown>;
|
||||
|
||||
export default function ArchivedChatsTable() {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const searchState = useRecoilValue(store.search);
|
||||
|
|
@ -56,7 +47,7 @@ export default function ArchivedChatsTable({
|
|||
const [queryParams, setQueryParams] = useState<ConversationListParams>(DEFAULT_PARAMS);
|
||||
const [deleteConversation, setDeleteConversation] = useState<TConversation | null>(null);
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isLoading } =
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isLoading, isFetching } =
|
||||
useConversationsInfiniteQuery(queryParams, {
|
||||
staleTime: 0,
|
||||
cacheTime: 5 * 60 * 1000,
|
||||
|
|
@ -65,31 +56,55 @@ export default function ArchivedChatsTable({
|
|||
});
|
||||
|
||||
const handleFilterChange = useCallback((value: string) => {
|
||||
const encodedValue = encodeURIComponent(value.trim());
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
search: encodedValue,
|
||||
search: value.trim(),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const debouncedFilterChange = useMemo(
|
||||
() => debounce(handleFilterChange, 300),
|
||||
[handleFilterChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedFilterChange.cancel();
|
||||
};
|
||||
}, [debouncedFilterChange]);
|
||||
|
||||
const allConversations = useMemo(() => {
|
||||
const allConversations = useMemo<ArchivedConversationRow[]>(() => {
|
||||
if (!data?.pages) {
|
||||
return [];
|
||||
}
|
||||
return data.pages.flatMap((page) => page?.conversations?.filter(Boolean) ?? []);
|
||||
return data.pages
|
||||
.flatMap((page) => page?.conversations?.filter(Boolean) ?? [])
|
||||
.map((conversation) => ({ ...conversation }));
|
||||
}, [data?.pages]);
|
||||
|
||||
const sorting = useMemo<SortingState>(
|
||||
() => [
|
||||
{
|
||||
id: queryParams.sortBy ?? 'createdAt',
|
||||
desc: queryParams.sortDirection === 'desc',
|
||||
},
|
||||
],
|
||||
[queryParams.sortBy, queryParams.sortDirection],
|
||||
);
|
||||
|
||||
const handleSortingChange = useCallback((updater: Updater<SortingState>) => {
|
||||
setQueryParams((prev) => {
|
||||
const currentSorting: SortingState = [
|
||||
{ id: prev.sortBy ?? 'createdAt', desc: prev.sortDirection === 'desc' },
|
||||
];
|
||||
const nextSorting = typeof updater === 'function' ? updater(currentSorting) : updater;
|
||||
const nextSort = nextSorting[0];
|
||||
|
||||
if (nextSort?.id !== 'title' && nextSort?.id !== 'createdAt') {
|
||||
return {
|
||||
...prev,
|
||||
sortBy: 'createdAt',
|
||||
sortDirection: 'desc',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
sortBy: nextSort.id,
|
||||
sortDirection: nextSort.desc ? 'desc' : 'asc',
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const deleteMutation = useDeleteConversationMutation({
|
||||
onSuccess: async () => {
|
||||
setIsDeleteOpen(false);
|
||||
|
|
@ -129,41 +144,11 @@ export default function ArchivedChatsTable({
|
|||
await fetchNextPage();
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
const columns = useMemo(
|
||||
const columns = useMemo<TableColumn<ArchivedConversationRow, unknown>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: ({ column }) => {
|
||||
const sortState = column.getIsSorted();
|
||||
let SortIcon = ArrowUpDown;
|
||||
let ariaSort: 'ascending' | 'descending' | 'none' = 'none';
|
||||
if (sortState === 'desc') {
|
||||
SortIcon = ArrowDown;
|
||||
ariaSort = 'descending';
|
||||
} else if (sortState === 'asc') {
|
||||
SortIcon = ArrowUp;
|
||||
ariaSort = 'ascending';
|
||||
}
|
||||
return (
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_name_sort')}
|
||||
side="top"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||
aria-sort={ariaSort}
|
||||
aria-label={localize('com_ui_name_sort')}
|
||||
aria-current={sortState ? 'true' : 'false'}
|
||||
>
|
||||
{localize('com_nav_archive_name')}
|
||||
<SortIcon className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
header: localize('com_nav_archive_name'),
|
||||
cell: ({ row }) => {
|
||||
const { conversationId, title } = row.original;
|
||||
return (
|
||||
|
|
@ -175,12 +160,14 @@ export default function ArchivedChatsTable({
|
|||
iconClassName="size-4"
|
||||
/>
|
||||
<Link
|
||||
to={`/c/${conversationId}`}
|
||||
to={`/c/${conversationId ?? ''}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-1 truncate rounded-sm text-link underline decoration-1 underline-offset-2 hover:decoration-2 focus:outline-none focus:ring-2 focus:ring-text-primary"
|
||||
title={title}
|
||||
aria-label={localize('com_ui_open_archived_chat_new_tab_title', { title })}
|
||||
className="group flex items-center gap-1 truncate rounded-sm text-link underline decoration-1 underline-offset-2 hover:decoration-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary"
|
||||
title={title ?? undefined}
|
||||
aria-label={localize('com_ui_open_archived_chat_new_tab_title', {
|
||||
title: title ?? localize('com_ui_untitled'),
|
||||
})}
|
||||
>
|
||||
<span className="truncate">{title}</span>
|
||||
<ExternalLink
|
||||
|
|
@ -192,56 +179,23 @@ export default function ArchivedChatsTable({
|
|||
);
|
||||
},
|
||||
meta: {
|
||||
size: isSmallScreen ? '70%' : '50%',
|
||||
mobileSize: '70%',
|
||||
width: 55,
|
||||
isRowHeader: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: ({ column }) => {
|
||||
const sortState = column.getIsSorted();
|
||||
let SortIcon = ArrowUpDown;
|
||||
let ariaSort: 'ascending' | 'descending' | 'none' = 'none';
|
||||
if (sortState === 'desc') {
|
||||
SortIcon = ArrowDown;
|
||||
ariaSort = 'descending';
|
||||
} else if (sortState === 'asc') {
|
||||
SortIcon = ArrowUp;
|
||||
ariaSort = 'ascending';
|
||||
}
|
||||
return (
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_date_sort')}
|
||||
side="top"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||
aria-sort={ariaSort}
|
||||
aria-label={localize('com_ui_date_sort')}
|
||||
aria-current={sortState ? 'true' : 'false'}
|
||||
>
|
||||
{localize('com_nav_archive_created_at')}
|
||||
<SortIcon className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
header: localize('com_nav_archive_created_at'),
|
||||
cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen),
|
||||
meta: {
|
||||
size: isSmallScreen ? '30%' : '35%',
|
||||
mobileSize: '30%',
|
||||
width: 25,
|
||||
desktopOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'actions',
|
||||
header: () => (
|
||||
<Label className="px-2 py-0 text-xs sm:px-2 sm:py-2 sm:text-sm">
|
||||
{localize('com_assistants_actions')}
|
||||
</Label>
|
||||
),
|
||||
id: 'actions',
|
||||
header: localize('com_assistants_actions'),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const conversation = row.original;
|
||||
return (
|
||||
|
|
@ -254,7 +208,7 @@ export default function ArchivedChatsTable({
|
|||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
onClick={() =>
|
||||
unarchiveConversation({
|
||||
conversationId: conversation.conversationId,
|
||||
conversationId: conversation.conversationId ?? '',
|
||||
isArchived: false,
|
||||
})
|
||||
}
|
||||
|
|
@ -285,8 +239,7 @@ export default function ArchivedChatsTable({
|
|||
);
|
||||
},
|
||||
meta: {
|
||||
size: '15%',
|
||||
mobileSize: '25%',
|
||||
width: 20,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
@ -298,23 +251,28 @@ export default function ArchivedChatsTable({
|
|||
{/* Fixed height keeps the loading (skeleton) and loaded states the same
|
||||
size, so the virtualized table can't reflow the dialog on load. */}
|
||||
<div className="h-[60vh]">
|
||||
<DataTable
|
||||
<VirtualizedDataTable
|
||||
columns={columns}
|
||||
data={allConversations}
|
||||
className="scrollbar-gutter-stable"
|
||||
filterColumn="title"
|
||||
onFilterChange={debouncedFilterChange}
|
||||
getRowId={(row, index) => row.conversationId ?? `archived-${index}`}
|
||||
className="scrollbar-gutter-stable h-full max-h-none"
|
||||
onFilterChange={handleFilterChange}
|
||||
filterValue={queryParams.search}
|
||||
fetchNextPage={handleFetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isFetching={isFetching}
|
||||
isLoading={isLoading}
|
||||
showCheckboxes={false}
|
||||
enableSearch={searchState.enabled === true}
|
||||
sorting={sorting}
|
||||
onSortingChange={handleSortingChange}
|
||||
config={{
|
||||
selection: { enableRowSelection: false, showCheckboxes: false },
|
||||
search: { enableSearch: searchState.enabled === true, debounce: 300 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<OGDialog open={isDeleteOpen} onOpenChange={onOpenChange}>
|
||||
<OGDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
||||
<OGDialogContent
|
||||
title={localize('com_ui_delete_confirm', {
|
||||
title: deleteConversation?.title ?? localize('com_ui_untitled'),
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ function SharedView() {
|
|||
const { theme, setTheme } = useContext(ThemeContext);
|
||||
const { shareId } = useParams();
|
||||
const { data: config } = useGetSharedStartupConfig(shareId);
|
||||
const { data, isLoading } = useGetSharedMessages(shareId ?? '');
|
||||
const { data, isLoading, refetch } = useGetSharedMessages(shareId ?? '');
|
||||
const dataTree = data && buildTree({ messages: data.messages });
|
||||
const messagesTree = dataTree?.length === 0 ? null : (dataTree ?? null);
|
||||
|
||||
|
|
@ -58,6 +58,14 @@ function SharedView() {
|
|||
if (status === 401) {
|
||||
return;
|
||||
}
|
||||
/** A 409 means the owner republished the link between the load and the
|
||||
* request, so the payload this fork was aimed at no longer exists. Pull
|
||||
* the current version in so a retry continues what is on screen. */
|
||||
if (status === 409) {
|
||||
void refetch();
|
||||
showToast({ message: localize('com_ui_shared_link_updated'), status: 'warning' });
|
||||
return;
|
||||
}
|
||||
showToast({
|
||||
message:
|
||||
status === 429
|
||||
|
|
@ -101,8 +109,12 @@ function SharedView() {
|
|||
if (shareId == null || shareId === '') {
|
||||
return;
|
||||
}
|
||||
forkSharedConvo({ shareId, targetMessageIndex: getActiveTargetIndex() });
|
||||
}, [shareId, forkSharedConvo, getActiveTargetIndex]);
|
||||
forkSharedConvo({
|
||||
shareId,
|
||||
targetMessageIndex: getActiveTargetIndex(),
|
||||
shareRevision: data?.updatedAt,
|
||||
});
|
||||
}, [shareId, forkSharedConvo, getActiveTargetIndex, data?.updatedAt]);
|
||||
|
||||
// configure document title
|
||||
let docTitle = '';
|
||||
|
|
|
|||
232
client/src/data-provider/__tests__/sharedLinksMutations.test.tsx
Normal file
232
client/src/data-provider/__tests__/sharedLinksMutations.test.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import React from 'react';
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { TConversation, TSharedLinkGetResponse } from 'librechat-data-provider';
|
||||
import type { InfiniteData } from '@tanstack/react-query';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCreateSharedLinkMutation, useDeleteSharedLinkMutation } from '../mutations';
|
||||
|
||||
const mockCreateSharedLink = jest.fn();
|
||||
const mockDeleteSharedLink = jest.fn();
|
||||
|
||||
jest.mock('librechat-data-provider', () => {
|
||||
const actual = jest.requireActual('librechat-data-provider');
|
||||
return {
|
||||
...actual,
|
||||
dataService: {
|
||||
...actual.dataService,
|
||||
createSharedLink: (...args: unknown[]) => mockCreateSharedLink(...args),
|
||||
deleteSharedLink: (...args: unknown[]) => mockDeleteSharedLink(...args),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function createQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const createWrapper = (queryClient: QueryClient) =>
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
};
|
||||
|
||||
type ConversationPages = InfiniteData<{
|
||||
conversations: TConversation[];
|
||||
nextCursor: string | null;
|
||||
}>;
|
||||
|
||||
const seedConversationList = (queryClient: QueryClient, isShared?: boolean) => {
|
||||
queryClient.setQueryData<ConversationPages>([QueryKeys.allConversations], {
|
||||
pages: [
|
||||
{
|
||||
conversations: [{ conversationId: 'conversation-1', isShared } as TConversation],
|
||||
nextCursor: null,
|
||||
},
|
||||
],
|
||||
pageParams: [],
|
||||
});
|
||||
};
|
||||
|
||||
const readSharedFlag = (queryClient: QueryClient) =>
|
||||
queryClient.getQueryData<ConversationPages>([QueryKeys.allConversations])?.pages[0]
|
||||
.conversations[0].isShared;
|
||||
|
||||
describe('shared-link mutation cache updates', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('marks the sidebar conversation as shared after creating a link', async () => {
|
||||
const queryClient = createQueryClient();
|
||||
seedConversationList(queryClient, false);
|
||||
mockCreateSharedLink.mockResolvedValue({
|
||||
_id: 'shared-link-id',
|
||||
shareId: 'share-1',
|
||||
conversationId: 'conversation-1',
|
||||
});
|
||||
const { result } = renderHook(() => useCreateSharedLinkMutation(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({ conversationId: 'conversation-1' });
|
||||
});
|
||||
|
||||
expect(readSharedFlag(queryClient)).toBe(true);
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it('clears the sidebar shared flag after deleting a link', async () => {
|
||||
const queryClient = createQueryClient();
|
||||
seedConversationList(queryClient, true);
|
||||
queryClient.setQueryData<TSharedLinkGetResponse>([QueryKeys.sharedLinks, 'conversation-1'], {
|
||||
shareId: 'share-1',
|
||||
conversationId: 'conversation-1',
|
||||
success: true,
|
||||
});
|
||||
mockDeleteSharedLink.mockResolvedValue({
|
||||
success: true,
|
||||
shareId: 'share-1',
|
||||
message: 'Share deleted successfully',
|
||||
});
|
||||
const { result } = renderHook(() => useDeleteSharedLinkMutation(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({ shareId: 'share-1' });
|
||||
});
|
||||
|
||||
expect(readSharedFlag(queryClient)).toBe(false);
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it('restores the sidebar shared flag when deleting a link fails', async () => {
|
||||
const queryClient = createQueryClient();
|
||||
seedConversationList(queryClient, true);
|
||||
queryClient.setQueryData<TSharedLinkGetResponse>([QueryKeys.sharedLinks, 'conversation-1'], {
|
||||
shareId: 'share-1',
|
||||
conversationId: 'conversation-1',
|
||||
success: true,
|
||||
});
|
||||
mockDeleteSharedLink.mockRejectedValue(new Error('network'));
|
||||
const { result } = renderHook(() => useDeleteSharedLinkMutation(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({ shareId: 'share-1' }).catch(() => undefined);
|
||||
});
|
||||
|
||||
// The link is still live, so the badge has to come back with it.
|
||||
expect(readSharedFlag(queryClient)).toBe(true);
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it('keeps the file opt-out in the cached link after creating it', async () => {
|
||||
const queryClient = createQueryClient();
|
||||
mockCreateSharedLink.mockResolvedValue({
|
||||
_id: 'shared-link-id',
|
||||
shareId: 'share-1',
|
||||
conversationId: 'conversation-1',
|
||||
});
|
||||
const { result } = renderHook(() => useCreateSharedLinkMutation(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({ conversationId: 'conversation-1', snapshotFiles: false });
|
||||
});
|
||||
|
||||
// The response never echoes the choice; without it the dialog reads the entry as
|
||||
// the enabled default and flips the switch back on.
|
||||
expect(
|
||||
queryClient.getQueryData<TSharedLinkGetResponse>([QueryKeys.sharedLinks, 'conversation-1'])
|
||||
?.snapshotFiles,
|
||||
).toBe(false);
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it('updates the active conversation link query after creating a link', async () => {
|
||||
const queryClient = createQueryClient();
|
||||
mockCreateSharedLink.mockResolvedValue({
|
||||
_id: 'shared-link-id',
|
||||
shareId: 'share-1',
|
||||
conversationId: 'conversation-1',
|
||||
});
|
||||
const { result } = renderHook(() => useCreateSharedLinkMutation(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({ conversationId: 'conversation-1' });
|
||||
});
|
||||
|
||||
expect(
|
||||
queryClient.getQueryData<TSharedLinkGetResponse>([QueryKeys.sharedLinks, 'conversation-1']),
|
||||
).toMatchObject({ shareId: 'share-1', success: true });
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it('preserves the stored file-sharing choice and marks the settings list stale', async () => {
|
||||
const queryClient = createQueryClient();
|
||||
const listKey = [QueryKeys.sharedLinks, { pageSize: 25, sortBy: 'createdAt' }];
|
||||
queryClient.setQueryData<TSharedLinkGetResponse>([QueryKeys.sharedLinks, 'conversation-1'], {
|
||||
shareId: 'share-0',
|
||||
conversationId: 'conversation-1',
|
||||
success: true,
|
||||
snapshotFiles: false,
|
||||
});
|
||||
queryClient.setQueryData(listKey, { pages: [], pageParams: [] });
|
||||
mockCreateSharedLink.mockResolvedValue({
|
||||
_id: 'shared-link-id',
|
||||
shareId: 'share-1',
|
||||
conversationId: 'conversation-1',
|
||||
});
|
||||
const { result } = renderHook(() => useCreateSharedLinkMutation(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({ conversationId: 'conversation-1' });
|
||||
});
|
||||
|
||||
expect(
|
||||
queryClient.getQueryData<TSharedLinkGetResponse>([QueryKeys.sharedLinks, 'conversation-1']),
|
||||
).toMatchObject({ shareId: 'share-1', success: true, snapshotFiles: false });
|
||||
expect(queryClient.getQueryState(listKey)?.isInvalidated).toBe(true);
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it('clears the active conversation indicator after deleting a link', async () => {
|
||||
const queryClient = createQueryClient();
|
||||
queryClient.setQueryData<TSharedLinkGetResponse>([QueryKeys.sharedLinks, 'conversation-1'], {
|
||||
shareId: 'share-1',
|
||||
conversationId: 'conversation-1',
|
||||
success: true,
|
||||
});
|
||||
mockDeleteSharedLink.mockResolvedValue({
|
||||
success: true,
|
||||
shareId: 'share-1',
|
||||
message: 'Share deleted successfully',
|
||||
});
|
||||
const { result } = renderHook(() => useDeleteSharedLinkMutation(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({ shareId: 'share-1' });
|
||||
});
|
||||
|
||||
expect(
|
||||
queryClient.getQueryData<TSharedLinkGetResponse>([QueryKeys.sharedLinks, 'conversation-1']),
|
||||
).toMatchObject({ shareId: null, success: false });
|
||||
queryClient.clear();
|
||||
});
|
||||
});
|
||||
|
|
@ -5,7 +5,7 @@ import {
|
|||
defaultAssistantsVersion,
|
||||
ConversationListResponse,
|
||||
} from 'librechat-data-provider';
|
||||
import type { InfiniteData, UseMutationResult } from '@tanstack/react-query';
|
||||
import type { InfiniteData, QueryClient, UseMutationResult } from '@tanstack/react-query';
|
||||
import type * as t from 'librechat-data-provider';
|
||||
import {
|
||||
logger,
|
||||
|
|
@ -170,6 +170,52 @@ export const usePinConversationMutation = (
|
|||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The sidebar badge reads `isShared` off the conversation list, which the server derives
|
||||
* from a different collection. Share mutations therefore have to flip it locally, or the
|
||||
* badge lags until the next conversation-list refetch.
|
||||
*/
|
||||
const setConversationSharedFlag = (
|
||||
queryClient: QueryClient,
|
||||
conversationId: string | null | undefined,
|
||||
isShared: boolean,
|
||||
): void => {
|
||||
if (conversationId == null || conversationId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
updateConvoInAllQueries(queryClient, conversationId, (convo) => ({ ...convo, isShared }));
|
||||
};
|
||||
|
||||
/**
|
||||
* Create and update return a bare `TSharedLinkResponse`, so the per-conversation
|
||||
* cache entry has to be lifted into the `TSharedLinkGetResponse` shape the UI reads
|
||||
* (`success` gates the dialog's copy and the header badge). `snapshotFiles` is never
|
||||
* echoed back and the settings list lives under a sibling key, so both are refetched
|
||||
* from the server rather than guessed at.
|
||||
*/
|
||||
const syncSharedLinkQueries = (
|
||||
queryClient: QueryClient,
|
||||
data: t.TSharedLinkResponse,
|
||||
requestedSnapshotFiles?: boolean,
|
||||
): void => {
|
||||
queryClient.setQueryData<t.TSharedLinkGetResponse>(
|
||||
[QueryKeys.sharedLinks, data.conversationId],
|
||||
(previous) => ({
|
||||
...previous,
|
||||
...data,
|
||||
// The response never echoes the file choice, and the dialog reads a resolved
|
||||
// entry with no choice as the enabled default, so an opt-out would flip back on
|
||||
// between here and the refetch.
|
||||
...(requestedSnapshotFiles !== undefined && { snapshotFiles: requestedSnapshotFiles }),
|
||||
success: true,
|
||||
}),
|
||||
);
|
||||
|
||||
setConversationSharedFlag(queryClient, data.conversationId, true);
|
||||
queryClient.invalidateQueries({ queryKey: [QueryKeys.sharedLinks], exact: false });
|
||||
};
|
||||
|
||||
export const useCreateSharedLinkMutation = (
|
||||
options?: t.MutationOptions<
|
||||
t.TCreateShareLinkRequest,
|
||||
|
|
@ -202,7 +248,7 @@ export const useCreateSharedLinkMutation = (
|
|||
},
|
||||
{
|
||||
onSuccess: (_data: t.TSharedLinkResponse, vars, context) => {
|
||||
queryClient.setQueryData([QueryKeys.sharedLinks, _data.conversationId], _data);
|
||||
syncSharedLinkQueries(queryClient, _data, vars.snapshotFiles);
|
||||
|
||||
onSuccess?.(_data, vars, context);
|
||||
},
|
||||
|
|
@ -234,7 +280,7 @@ export const useUpdateSharedLinkMutation = (
|
|||
},
|
||||
{
|
||||
onSuccess: (_data: t.TSharedLinkResponse, vars, context) => {
|
||||
queryClient.setQueryData([QueryKeys.sharedLinks, _data.conversationId], _data);
|
||||
syncSharedLinkQueries(queryClient, _data, vars.snapshotFiles);
|
||||
|
||||
onSuccess?.(_data, vars, context);
|
||||
},
|
||||
|
|
@ -262,12 +308,24 @@ export const useDeleteSharedLinkMutation = (
|
|||
});
|
||||
|
||||
const previousQueries = new Map();
|
||||
const unsharedConversationIds = new Set<string | null>();
|
||||
const queryKeys = queryClient.getQueryCache().findAll([QueryKeys.sharedLinks]);
|
||||
|
||||
queryKeys.forEach((query) => {
|
||||
const previousData = queryClient.getQueryData(query.queryKey);
|
||||
previousQueries.set(query.queryKey, previousData);
|
||||
|
||||
const sharedLink = previousData as t.TSharedLinkGetResponse | undefined;
|
||||
if (sharedLink?.shareId === vars.shareId) {
|
||||
unsharedConversationIds.add(sharedLink.conversationId);
|
||||
queryClient.setQueryData<t.TSharedLinkGetResponse>(query.queryKey, {
|
||||
...sharedLink,
|
||||
success: false,
|
||||
shareId: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData<t.SharedLinkQueryData>(query.queryKey, (old) => {
|
||||
if (!old?.pages) {
|
||||
return old;
|
||||
|
|
@ -275,7 +333,13 @@ export const useDeleteSharedLinkMutation = (
|
|||
|
||||
const updatedPages = old.pages.map((page) => ({
|
||||
...page,
|
||||
links: page.links.filter((link) => link.shareId !== vars.shareId),
|
||||
links: page.links.filter((link) => {
|
||||
if (link.shareId !== vars.shareId) {
|
||||
return true;
|
||||
}
|
||||
unsharedConversationIds.add(link.conversationId);
|
||||
return false;
|
||||
}),
|
||||
}));
|
||||
|
||||
const nonEmptyPages = updatedPages.filter((page) => page.links.length > 0);
|
||||
|
|
@ -287,7 +351,11 @@ export const useDeleteSharedLinkMutation = (
|
|||
});
|
||||
});
|
||||
|
||||
return { previousQueries };
|
||||
for (const conversationId of unsharedConversationIds) {
|
||||
setConversationSharedFlag(queryClient, conversationId, false);
|
||||
}
|
||||
|
||||
return { previousQueries, unsharedConversationIds };
|
||||
},
|
||||
|
||||
onError: (_err, _vars, context) => {
|
||||
|
|
@ -296,6 +364,11 @@ export const useDeleteSharedLinkMutation = (
|
|||
queryClient.setQueryData(prevQueryKey as string[], prevData);
|
||||
});
|
||||
}
|
||||
// The badge lives on the conversation caches, which the snapshot above does not
|
||||
// cover, so a failed delete would leave the conversation looking unshared.
|
||||
context?.unsharedConversationIds?.forEach((conversationId) => {
|
||||
setConversationSharedFlag(queryClient, conversationId, true);
|
||||
});
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
|
|
@ -303,6 +376,11 @@ export const useDeleteSharedLinkMutation = (
|
|||
queryKey: [QueryKeys.sharedLinks],
|
||||
exact: false,
|
||||
});
|
||||
/* A conversation can hold several links (one per target message), so clearing the
|
||||
badge optimistically is only a guess. Let the server, which derives `isShared`
|
||||
from the links that are actually left, settle it. Every cached page refetches:
|
||||
the affected conversation is as likely to sit on page three as on page one. */
|
||||
queryClient.invalidateQueries({ queryKey: [QueryKeys.allConversations] });
|
||||
},
|
||||
|
||||
onSuccess: (data, variables) => {
|
||||
|
|
@ -705,7 +783,11 @@ export const useForkSharedConvoMutation = (
|
|||
|
||||
return useMutation(
|
||||
(payload: t.TForkSharedConvoRequest) =>
|
||||
dataService.forkSharedConversation(payload.shareId, payload.targetMessageIndex),
|
||||
dataService.forkSharedConversation(
|
||||
payload.shareId,
|
||||
payload.targetMessageIndex,
|
||||
payload.shareRevision,
|
||||
),
|
||||
{
|
||||
onSuccess: (data, vars, context) => {
|
||||
const forkedConversation = data.conversation;
|
||||
|
|
|
|||
|
|
@ -1032,6 +1032,7 @@
|
|||
"com_ui_control_bar": "Control bar",
|
||||
"com_ui_conversation": "conversation",
|
||||
"com_ui_conversation_label": "{{title}} conversation",
|
||||
"com_ui_conversation_label_shared": "{{title}} conversation, shared link active",
|
||||
"com_ui_conversation_not_found": "Conversation not found",
|
||||
"com_ui_conversation_summarized": "Conversation summarized",
|
||||
"com_ui_conversations": "conversations",
|
||||
|
|
@ -1053,7 +1054,7 @@
|
|||
"com_ui_create": "Create",
|
||||
"com_ui_create_api_key": "Create API Key",
|
||||
"com_ui_create_assistant": "Create Assistant",
|
||||
"com_ui_create_link": "Create link",
|
||||
"com_ui_create_link": "Create a shared link",
|
||||
"com_ui_create_mcp_server": "Create MCP server",
|
||||
"com_ui_create_memory": "Create Memory",
|
||||
"com_ui_create_new_agent": "Create New Agent",
|
||||
|
|
@ -1097,6 +1098,7 @@
|
|||
"com_ui_default": "Default",
|
||||
"com_ui_default_post_request": "Default (POST request)",
|
||||
"com_ui_delete": "Delete",
|
||||
"com_ui_delete_link": "Delete Link",
|
||||
"com_ui_delete_action": "Delete Action",
|
||||
"com_ui_delete_action_confirm": "Are you sure you want to delete this action?",
|
||||
"com_ui_delete_agent": "Delete Agent",
|
||||
|
|
@ -1104,6 +1106,7 @@
|
|||
"com_ui_delete_api_key": "Delete API Key",
|
||||
"com_ui_delete_assistant": "Delete Assistant",
|
||||
"com_ui_delete_assistant_confirm": "Are you sure you want to delete this Assistant? This cannot be undone.",
|
||||
"com_ui_bulk_delete_error": "Some items could not be deleted",
|
||||
"com_ui_delete_confirm": "This will delete",
|
||||
"com_ui_delete_confirm_prompt_version_var": "This will delete the selected version for \"{{0}}.\" If no other versions exist, the prompt will be deleted.",
|
||||
"com_ui_delete_confirm_strong": "This will delete <strong>{{title}}</strong>",
|
||||
|
|
@ -1200,6 +1203,7 @@
|
|||
"com_ui_export_file_search": "File Search",
|
||||
"com_ui_export_image": "Image",
|
||||
"com_ui_export_retrieval": "Retrieval",
|
||||
"com_ui_export_share_link_active": "Export/Share, link active",
|
||||
"com_ui_export_steer": "You (steered)",
|
||||
"com_ui_export_summary": "Summary",
|
||||
"com_ui_export_tool": "Tool",
|
||||
|
|
@ -1405,7 +1409,7 @@
|
|||
"com_ui_line_count": "{0} line",
|
||||
"com_ui_lines_count": "{0} lines",
|
||||
"com_ui_link_copied": "Link copied",
|
||||
"com_ui_link_refreshed": "Link refreshed",
|
||||
"com_ui_link_refreshed": "Link updated",
|
||||
"com_ui_live": "live",
|
||||
"com_ui_load_more": "Load more",
|
||||
"com_ui_loading": "Loading...",
|
||||
|
|
@ -1555,6 +1559,7 @@
|
|||
"com_ui_no_categories": "No categories available",
|
||||
"com_ui_no_category": "No category",
|
||||
"com_ui_no_changes": "No changes were made",
|
||||
"com_ui_no_valid_items": "No valid items were selected",
|
||||
"com_ui_no_individual_access": "No individual users or groups have access to this agent",
|
||||
"com_ui_no_labels": "No Labels",
|
||||
"com_ui_no_mcp_servers": "No MCP servers yet",
|
||||
|
|
@ -1687,7 +1692,6 @@
|
|||
"com_ui_reference_saved_memories_description": "Allow the assistant to reference and use your saved memories when responding",
|
||||
"com_ui_referenced_quotes": "Referenced quotes",
|
||||
"com_ui_refresh": "Refresh",
|
||||
"com_ui_refresh_link": "Refresh link",
|
||||
"com_ui_refresh_page": "Refresh page",
|
||||
"com_ui_regenerate": "Regenerate",
|
||||
"com_ui_regenerate_backup": "Regenerate Backup Codes",
|
||||
|
|
@ -1858,15 +1862,17 @@
|
|||
"com_ui_share_everyone_description_var": "This {{resource}} will be available to everyone. Please make sure the {{resource}} is really meant to be shared with everyone. Be careful with your data.",
|
||||
"com_ui_share_files": "Share files in this conversation",
|
||||
"com_ui_share_files_description": "Images and files in this conversation won't be visible to viewers unless this is enabled.",
|
||||
"com_ui_share_files_refresh_note": "Refresh the link to apply this change — files are snapshotted when the link is refreshed.",
|
||||
"com_ui_share_files_update_note": "Choose your setting, then select Update link. The same URL will include the latest messages and file choice.",
|
||||
"com_ui_share_link_to_chat": "Share link to chat",
|
||||
"com_ui_share_qr_code_description": "QR code for sharing this conversation link",
|
||||
"com_ui_share_update_message": "Your name, custom instructions, and any messages you add after sharing stay private.",
|
||||
"com_ui_share_update_message": "Your name and custom instructions stay private. Edits to shared messages appear right away; select Update link to include new messages without changing the URL.",
|
||||
"com_ui_share_var": "Share {{0}}",
|
||||
"com_ui_shared_link": "shared link",
|
||||
"com_ui_shared_link_bulk_delete_success": "Successfully deleted shared links",
|
||||
"com_ui_shared_link_delete_success": "Successfully deleted shared link",
|
||||
"com_ui_shared_link_manage_access": "Manage Access",
|
||||
"com_ui_shared_link_not_found": "Shared link not found",
|
||||
"com_ui_shared_link_updated": "This shared link was updated. Reloading the latest version, then try again",
|
||||
"com_ui_shared_prompts": "Shared Prompts",
|
||||
"com_ui_shop": "Shopping",
|
||||
"com_ui_show": "Show",
|
||||
|
|
@ -2113,6 +2119,9 @@
|
|||
"com_ui_untitled": "Untitled",
|
||||
"com_ui_update": "Update",
|
||||
"com_ui_update_mcp_server": "Update MCP server",
|
||||
"com_ui_update_shared_link": "Update link",
|
||||
"com_ui_update_shared_link_confirm_description": "This publishes the latest messages and your current file-sharing choice to the existing link. The URL stays the same, and anyone with access can see the updated snapshot.",
|
||||
"com_ui_update_shared_link_confirm_title": "Update shared link?",
|
||||
"com_ui_updated_file": "Updated {{0}}",
|
||||
"com_ui_updating": "Updating...",
|
||||
"com_ui_upload": "Upload",
|
||||
|
|
|
|||
|
|
@ -180,6 +180,7 @@ html {
|
|||
--surface-destructive: var(--red-700);
|
||||
--surface-destructive-hover: var(--red-800);
|
||||
--surface-chat: var(--white);
|
||||
--surface-qr: var(--white);
|
||||
--border-light: var(--gray-200);
|
||||
--border-light-alpha: 1;
|
||||
--border-medium-alt: var(--gray-300);
|
||||
|
|
@ -255,6 +256,7 @@ html {
|
|||
--surface-destructive: var(--red-800);
|
||||
--surface-destructive-hover: var(--red-900);
|
||||
--surface-chat: var(--gray-700);
|
||||
--surface-qr: var(--white);
|
||||
--border-light: var(--gray-700);
|
||||
--border-medium-alt: var(--gray-600);
|
||||
--border-medium: var(--gray-600);
|
||||
|
|
|
|||
|
|
@ -655,6 +655,32 @@ describe('Conversation Utilities', () => {
|
|||
expect(data!.pages[0].conversations[0].model).toBe('gpt-4');
|
||||
});
|
||||
|
||||
it('updateConvoInAllQueries keeps the derived isShared flag when a caller replaces the convo', () => {
|
||||
updateConvoInAllQueries(queryClient, 'a', (c) => ({ ...c, isShared: true }));
|
||||
// Rename/pin swap in a server payload that has no `isShared` field.
|
||||
updateConvoInAllQueries(
|
||||
queryClient,
|
||||
'a',
|
||||
() =>
|
||||
({
|
||||
conversationId: 'a',
|
||||
title: 'Renamed',
|
||||
}) as TConversation,
|
||||
);
|
||||
|
||||
const data = queryClient.getQueryData<InfiniteData<any>>(['allConversations']);
|
||||
expect(data!.pages[0].conversations[0].title).toBe('Renamed');
|
||||
expect(data!.pages[0].conversations[0].isShared).toBe(true);
|
||||
});
|
||||
|
||||
it('updateConvoInAllQueries lets an explicit isShared value win over the cached one', () => {
|
||||
updateConvoInAllQueries(queryClient, 'a', (c) => ({ ...c, isShared: true }));
|
||||
updateConvoInAllQueries(queryClient, 'a', (c) => ({ ...c, isShared: false }));
|
||||
|
||||
const data = queryClient.getQueryData<InfiniteData<any>>(['allConversations']);
|
||||
expect(data!.pages[0].conversations[0].isShared).toBe(false);
|
||||
});
|
||||
|
||||
it('updateConvoInAllQueries with moveToTop moves convo to front and updates updatedAt', () => {
|
||||
// Add more conversations so 'a' is not at position 0
|
||||
const convoC = { conversationId: 'c', updatedAt: '2024-01-03T12:00:00Z' } as TConversation;
|
||||
|
|
|
|||
|
|
@ -530,9 +530,16 @@ export function updateConvoInAllQueries(
|
|||
}
|
||||
|
||||
const found = oldData.pages[pageIdx].conversations[convoIdx];
|
||||
const updated = moveToTop
|
||||
? { ...updater(found), updatedAt: new Date().toISOString() }
|
||||
: updater(found);
|
||||
/** `isShared` is derived per list request from the shared-links collection and is
|
||||
* absent from single-conversation payloads, so callers that swap in a server
|
||||
* response wholesale (rename, pin, SSE updates) would otherwise drop the sidebar
|
||||
* badge until an unrelated list refetch. Carry it forward when the updater omits it. */
|
||||
const next = updater(found);
|
||||
const merged =
|
||||
next.isShared === undefined && found.isShared !== undefined
|
||||
? { ...next, isShared: found.isShared }
|
||||
: next;
|
||||
const updated = moveToTop ? { ...merged, updatedAt: new Date().toISOString() } : merged;
|
||||
|
||||
if (!conversationMatchesProjectQuery(query.queryKey, updated)) {
|
||||
return removeConvoFromInfinitePages(oldData, conversationId);
|
||||
|
|
|
|||
|
|
@ -60,6 +60,10 @@ module.exports = {
|
|||
'0%, 100%': { opacity: '1' },
|
||||
'50%': { opacity: '0' },
|
||||
},
|
||||
'refresh-link-spin': {
|
||||
from: { transform: 'rotate(0deg)' },
|
||||
to: { transform: 'rotate(360deg)' },
|
||||
},
|
||||
'reset-spin': {
|
||||
from: { transform: 'rotate(0deg)' },
|
||||
to: { transform: 'rotate(-360deg)' },
|
||||
|
|
@ -75,6 +79,7 @@ module.exports = {
|
|||
'slide-out-right': 'slide-out-right 300ms cubic-bezier(0.25, 0.1, 0.25, 1)',
|
||||
'shortcut-shake': 'shortcut-shake 0.25s ease-in-out',
|
||||
'logo-blink': 'logo-blink 3s infinite',
|
||||
'refresh-link-spin': 'refresh-link-spin 650ms cubic-bezier(0.42, 0, 0.58, 1)',
|
||||
'reset-spin': 'reset-spin 500ms cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
},
|
||||
colors: createTailwindColors(),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { MongoClient } from 'mongodb';
|
||||
import type { Collection, ObjectId } from 'mongodb';
|
||||
import { applyRuntimeEnv } from '../../setup/runtimeEnv';
|
||||
import {
|
||||
MOCK_ENDPOINTS,
|
||||
MOCK_REPLY_TEXT,
|
||||
NEW_CHAT_PATH,
|
||||
mockReply,
|
||||
selectMockEndpoint,
|
||||
|
|
@ -35,8 +35,67 @@ type AclEntryDoc = {
|
|||
resourceId: ObjectId;
|
||||
};
|
||||
|
||||
type UploadFixture = {
|
||||
name: string;
|
||||
mimeType: string;
|
||||
buffer: Buffer;
|
||||
};
|
||||
|
||||
type PublicSharedFile = {
|
||||
file_id?: string;
|
||||
filename?: string;
|
||||
filepath?: string;
|
||||
};
|
||||
|
||||
type PublicSharedPayload = {
|
||||
messages?: Array<{
|
||||
files?: PublicSharedFile[];
|
||||
attachments?: PublicSharedFile[];
|
||||
}>;
|
||||
};
|
||||
|
||||
const randomSuffix = () => `${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
|
||||
async function uploadProviderFile(page: Page, fixture: UploadFixture) {
|
||||
await page.getByRole('button', { name: 'Attach File Options' }).click();
|
||||
const uploadOption = page.getByText('Upload to Provider', { exact: true });
|
||||
await expect(uploadOption).toBeVisible();
|
||||
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
await uploadOption.click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
expect(await fileChooser.element().getAttribute('type')).toBe('file');
|
||||
|
||||
const uploadResponsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === 'POST' &&
|
||||
response.url().includes('/api/files') &&
|
||||
response.status() === 200,
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
await fileChooser.setFiles(fixture);
|
||||
const uploadResponse = await uploadResponsePromise;
|
||||
expect(uploadResponse.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
async function openPublicSharedLink(
|
||||
page: Page,
|
||||
pathname: string,
|
||||
shareId: string,
|
||||
): Promise<PublicSharedPayload> {
|
||||
const payloadResponsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === 'GET' &&
|
||||
new URL(response.url()).pathname === `/api/share/${shareId}` &&
|
||||
response.status() === 200,
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
await page.goto(pathname, { timeout: 10000 });
|
||||
const payloadResponse = await payloadResponsePromise;
|
||||
expect(payloadResponse.ok()).toBeTruthy();
|
||||
return (await payloadResponse.json()) as PublicSharedPayload;
|
||||
}
|
||||
|
||||
async function connectToE2EDb() {
|
||||
applyRuntimeEnv();
|
||||
if (!process.env.MONGO_URI) {
|
||||
|
|
@ -67,26 +126,40 @@ async function waitForSharedLink(
|
|||
}
|
||||
|
||||
test.describe('shared links', () => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
test('creates a shared link and preserves legacy public links through runtime migration', async ({
|
||||
test('manages a shared-link snapshot and preserves legacy public links through runtime migration', async ({
|
||||
page,
|
||||
baseURL,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
if (typeof baseURL !== 'string') {
|
||||
throw new Error('baseURL must be configured for shared-link mock e2e tests');
|
||||
}
|
||||
|
||||
const suffix = randomSuffix();
|
||||
const userMessage = `Shared link e2e ${suffix}`;
|
||||
const updatedMessage = `Updated shared link e2e ${suffix}`;
|
||||
const fileFixture: UploadFixture = {
|
||||
name: `shared-link-${suffix}.txt`,
|
||||
mimeType: 'text/plain',
|
||||
buffer: Buffer.from(`Shared link file fixture ${suffix}\n`),
|
||||
};
|
||||
|
||||
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
|
||||
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
|
||||
await uploadProviderFile(page, fileFixture);
|
||||
await expect(page.getByRole('button', { name: fileFixture.name, exact: true })).toBeVisible();
|
||||
|
||||
const response = await sendMessage(page, userMessage);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
await expect(page.getByText(userMessage)).toBeVisible();
|
||||
await expect(page.getByText(userMessage, { exact: true })).toBeVisible();
|
||||
await expect(mockReply(page)).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId('messages-view').getByRole('button', {
|
||||
name: fileFixture.name,
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(/\/c\/(?!new)[0-9a-fA-F-]{36}$/);
|
||||
const conversationUrl = new URL(page.url());
|
||||
|
|
@ -95,9 +168,16 @@ test.describe('shared links', () => {
|
|||
throw new Error(`Could not parse conversation id from ${conversationUrl.href}`);
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Export options' }).click();
|
||||
await page.getByRole('button', { name: 'Export/Share' }).click();
|
||||
await page.getByTestId('share-conversation-menu-item').click();
|
||||
await expect(page.getByRole('dialog', { name: 'Share link to chat' })).toBeVisible();
|
||||
const shareDialog = page.getByRole('dialog', { name: 'Share link to chat' });
|
||||
await expect(shareDialog).toBeVisible();
|
||||
const shareFilesSwitch = shareDialog.getByRole('switch', {
|
||||
name: 'Share files in this conversation',
|
||||
});
|
||||
await expect(shareFilesSwitch).toBeChecked();
|
||||
await shareFilesSwitch.click();
|
||||
await expect(shareFilesSwitch).not.toBeChecked();
|
||||
|
||||
const [shareResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
|
|
@ -107,25 +187,109 @@ test.describe('shared links', () => {
|
|||
res.status() === 200,
|
||||
{ timeout: 30000 },
|
||||
),
|
||||
page.getByRole('button', { name: 'Create link' }).click(),
|
||||
page.getByRole('button', { name: 'Create a shared link' }).click(),
|
||||
]);
|
||||
expect(shareResponse.ok()).toBeTruthy();
|
||||
const createBody = shareResponse.request().postDataJSON() as { snapshotFiles?: boolean };
|
||||
expect(createBody.snapshotFiles).toBe(false);
|
||||
const sharePayload = (await shareResponse.json()) as { shareId?: string };
|
||||
if (!sharePayload.shareId) {
|
||||
throw new Error('Expected create-share response to include a shareId');
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('shared-link-url')).toContainText('/share/');
|
||||
/** The share URL is rendered into a read-only <input>, so assert on its value. */
|
||||
const sharedLinkInput = page.getByTestId('shared-link-url');
|
||||
await expect(sharedLinkInput).toHaveValue(/\/share\//);
|
||||
await expect(page.getByRole('button', { name: 'Manage Access' })).toBeVisible();
|
||||
const sharedLinkUrl = (await page.getByTestId('shared-link-url').textContent())?.trim();
|
||||
const sharedLinkUrl = (await sharedLinkInput.inputValue()).trim();
|
||||
if (!sharedLinkUrl) {
|
||||
throw new Error('Expected shared-link URL to be rendered after creating a link');
|
||||
}
|
||||
|
||||
await page.goto(new URL(sharedLinkUrl, baseURL).pathname, { timeout: 10000 });
|
||||
/** The header trigger flips to the "link active" label once a share exists. */
|
||||
await expect(page.getByTestId('header-shared-link-indicator')).toBeVisible();
|
||||
|
||||
const publicSharePath = new URL(sharedLinkUrl, baseURL).pathname;
|
||||
const optedOutPayload = await openPublicSharedLink(page, publicSharePath, sharePayload.shareId);
|
||||
await expect(page).toHaveURL(/\/share\/.+/);
|
||||
await expect(page.getByTestId('messages-view').getByText(userMessage)).toBeVisible();
|
||||
await expect(mockReply(page)).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId('messages-view').getByText(userMessage, { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(mockReply(page)).toHaveCount(1);
|
||||
const optedOutFiles = (optedOutPayload.messages ?? []).flatMap((message) => [
|
||||
...(message.files ?? []),
|
||||
...(message.attachments ?? []),
|
||||
]);
|
||||
expect(optedOutFiles).toHaveLength(0);
|
||||
await expect(
|
||||
page.getByTestId('messages-view').getByRole('button', {
|
||||
name: fileFixture.name,
|
||||
exact: true,
|
||||
}),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.goto(conversationUrl.pathname, { timeout: 10000 });
|
||||
const updateResponse = await sendMessage(page, updatedMessage);
|
||||
expect(updateResponse.ok()).toBeTruthy();
|
||||
await expect(page.getByText(updatedMessage)).toBeVisible();
|
||||
|
||||
/** A shared link remains a snapshot until its owner explicitly updates it. */
|
||||
await page.goto(publicSharePath, { timeout: 10000 });
|
||||
await expect(page.getByTestId('messages-view').getByText(updatedMessage)).toHaveCount(0);
|
||||
await expect(mockReply(page)).toHaveCount(1);
|
||||
|
||||
await page.goto(conversationUrl.pathname, { timeout: 10000 });
|
||||
await page.getByRole('button', { name: 'Export/Share' }).click();
|
||||
await page.getByTestId('share-conversation-menu-item').click();
|
||||
await expect(shareDialog).toBeVisible();
|
||||
await expect(shareFilesSwitch).not.toBeChecked();
|
||||
await shareFilesSwitch.click();
|
||||
await expect(shareFilesSwitch).toBeChecked();
|
||||
await shareDialog.getByRole('button', { name: 'Update link', exact: true }).click();
|
||||
|
||||
const updateDialog = page.getByRole('dialog', { name: 'Update shared link?' });
|
||||
await expect(updateDialog).toBeVisible();
|
||||
await expect(
|
||||
updateDialog.getByText(/This publishes the latest messages.+The URL stays the same/),
|
||||
).toBeVisible();
|
||||
|
||||
const [refreshResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.request().method() === 'PATCH' &&
|
||||
res.url().includes(`/api/share/${sharePayload.shareId}`) &&
|
||||
res.status() === 200,
|
||||
{ timeout: 30000 },
|
||||
),
|
||||
updateDialog.getByRole('button', { name: 'Update link', exact: true }).click(),
|
||||
]);
|
||||
expect(refreshResponse.ok()).toBeTruthy();
|
||||
const updateBody = refreshResponse.request().postDataJSON() as { snapshotFiles?: boolean };
|
||||
expect(updateBody.snapshotFiles).toBe(true);
|
||||
await expect(updateDialog).toBeHidden();
|
||||
await expect(sharedLinkInput).toHaveValue(sharedLinkUrl);
|
||||
|
||||
const optedInPayload = await openPublicSharedLink(page, publicSharePath, sharePayload.shareId);
|
||||
await expect(page.getByTestId('messages-view').getByText(updatedMessage)).toBeVisible();
|
||||
await expect(mockReply(page)).toHaveCount(2);
|
||||
const sharedFiles = (optedInPayload.messages ?? []).flatMap((message) => [
|
||||
...(message.files ?? []),
|
||||
...(message.attachments ?? []),
|
||||
]);
|
||||
const sharedFile = sharedFiles.find((file) => file.filename === fileFixture.name);
|
||||
expect(sharedFile).toBeDefined();
|
||||
if (!sharedFile?.file_id) {
|
||||
throw new Error(`Expected shared file ${fileFixture.name} to include a file_id`);
|
||||
}
|
||||
expect(sharedFile.filepath).toBe(
|
||||
`/api/share/${sharePayload.shareId}/files/${sharedFile.file_id}`,
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId('messages-view').getByRole('button', {
|
||||
name: fileFixture.name,
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
const { client, db } = await connectToE2EDb();
|
||||
const aclEntries = db.collection<AclEntryDoc>('aclentries');
|
||||
|
|
@ -150,8 +314,10 @@ test.describe('shared links', () => {
|
|||
legacyResourceId = resourceId;
|
||||
|
||||
await page.goto(`/share/${legacyShareId}`, { timeout: 10000 });
|
||||
await expect(page.getByTestId('messages-view').getByText(userMessage)).toBeVisible();
|
||||
await expect(mockReply(page)).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId('messages-view').getByText(userMessage, { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(mockReply(page).first()).toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
|
|
@ -183,5 +349,30 @@ test.describe('shared links', () => {
|
|||
}
|
||||
await client.close();
|
||||
}
|
||||
|
||||
await page.goto(conversationUrl.pathname, { timeout: 10000 });
|
||||
await page.getByRole('button', { name: 'Export/Share' }).click();
|
||||
await page.getByTestId('share-conversation-menu-item').click();
|
||||
await expect(shareDialog).toBeVisible();
|
||||
await shareDialog.getByRole('button', { name: 'Delete Link' }).click();
|
||||
|
||||
const deleteDialog = page.getByRole('alertdialog', { name: 'Delete Shared Link' });
|
||||
await expect(deleteDialog).toBeVisible();
|
||||
const [deleteResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.request().method() === 'DELETE' &&
|
||||
res.url().includes(`/api/share/${sharePayload.shareId}`) &&
|
||||
res.status() === 200,
|
||||
{ timeout: 30000 },
|
||||
),
|
||||
deleteDialog.getByRole('button', { name: 'Delete Link' }).click(),
|
||||
]);
|
||||
expect(deleteResponse.ok()).toBeTruthy();
|
||||
await expect(deleteDialog).toBeHidden();
|
||||
await expect(shareDialog).toBeVisible();
|
||||
await expect(shareDialog.getByRole('button', { name: 'Create a shared link' })).toBeVisible();
|
||||
await expect(sharedLinkInput).toHaveCount(0);
|
||||
await expect(page.getByTestId('header-shared-link-indicator')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ export * from './cache';
|
|||
export * from './shared-links/access';
|
||||
export * from './shared-links/service';
|
||||
export * from './shared-links/config';
|
||||
export * from './shared-links/http';
|
||||
/* Stream */
|
||||
export * from './stream';
|
||||
/* Diagnostics */
|
||||
|
|
|
|||
|
|
@ -75,6 +75,9 @@ function createRes(): Response & { _status: number; _json: unknown } {
|
|||
res._json = body;
|
||||
return res;
|
||||
},
|
||||
end() {
|
||||
return res;
|
||||
},
|
||||
};
|
||||
return res as unknown as Response & { _status: number; _json: unknown };
|
||||
}
|
||||
|
|
@ -248,6 +251,7 @@ describe('canAccessSharedLink', () => {
|
|||
await canAccessSharedLink(req, res, next as unknown as NextFunction);
|
||||
|
||||
expect(res._status).toBe(403);
|
||||
expect(res._json).toBeNull();
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ export function createSharedLinkAccessMiddleware(deps: SharedLinkAccessDeps) {
|
|||
});
|
||||
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({ message: 'You do not have permission to view this shared link' });
|
||||
res.status(403).end();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
100
packages/api/src/shared-links/http.test.ts
Normal file
100
packages/api/src/shared-links/http.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import {
|
||||
buildShareFileEtag,
|
||||
parseSharedLinksPageSize,
|
||||
isValidSharedLinksCursor,
|
||||
DEFAULT_SHARED_LINKS_PAGE_SIZE,
|
||||
MAX_SHARED_LINKS_PAGE_SIZE,
|
||||
} from './http';
|
||||
|
||||
const encodeCursor = (payload: Record<string, unknown>): string =>
|
||||
Buffer.from(JSON.stringify(payload)).toString('base64');
|
||||
|
||||
describe('parseSharedLinksPageSize', () => {
|
||||
it('falls back to the default for anything unparseable', () => {
|
||||
expect(parseSharedLinksPageSize(undefined)).toBe(DEFAULT_SHARED_LINKS_PAGE_SIZE);
|
||||
expect(parseSharedLinksPageSize('')).toBe(DEFAULT_SHARED_LINKS_PAGE_SIZE);
|
||||
expect(parseSharedLinksPageSize('ten')).toBe(DEFAULT_SHARED_LINKS_PAGE_SIZE);
|
||||
expect(parseSharedLinksPageSize('1e3')).toBe(DEFAULT_SHARED_LINKS_PAGE_SIZE);
|
||||
expect(parseSharedLinksPageSize(25)).toBe(DEFAULT_SHARED_LINKS_PAGE_SIZE);
|
||||
});
|
||||
|
||||
it('clamps to a sane range', () => {
|
||||
expect(parseSharedLinksPageSize('1000')).toBe(MAX_SHARED_LINKS_PAGE_SIZE);
|
||||
expect(parseSharedLinksPageSize('0')).toBe(1);
|
||||
expect(parseSharedLinksPageSize('-5')).toBe(1);
|
||||
expect(parseSharedLinksPageSize('25')).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidSharedLinksCursor', () => {
|
||||
const id = '0123456789abcdef01234567';
|
||||
|
||||
it('accepts a composite cursor for either sort', () => {
|
||||
expect(isValidSharedLinksCursor(encodeCursor({ primary: 'Title', id }), 'title')).toBe(true);
|
||||
expect(
|
||||
isValidSharedLinksCursor(
|
||||
encodeCursor({ primary: '2026-01-01T00:00:00.000Z', id }),
|
||||
'createdAt',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a titleless boundary only where the field can be absent', () => {
|
||||
expect(isValidSharedLinksCursor(encodeCursor({ primary: null, id }), 'title')).toBe(true);
|
||||
// createdAt is always stamped, so a null boundary could not have been issued.
|
||||
expect(isValidSharedLinksCursor(encodeCursor({ primary: null, id }), 'createdAt')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a composite cursor whose id could not have been issued', () => {
|
||||
// Without a usable id it is not a composite cursor, so it has to stand on its own
|
||||
// as a legacy value: for a createdAt page that means parsing as a date.
|
||||
expect(
|
||||
isValidSharedLinksCursor(
|
||||
encodeCursor({ primary: '2026-01-01T00:00:00.000Z', id: 'nope' }),
|
||||
'createdAt',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('still accepts the plain value older links carry', () => {
|
||||
expect(isValidSharedLinksCursor('2026-01-01T00:00:00.000Z', 'createdAt')).toBe(true);
|
||||
expect(isValidSharedLinksCursor('not-a-date', 'createdAt')).toBe(false);
|
||||
expect(isValidSharedLinksCursor('Some Title', 'title')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a cursor carrying a long title but rejects an absurd one', () => {
|
||||
const longTitle = 'T'.repeat(1024);
|
||||
expect(isValidSharedLinksCursor(encodeCursor({ primary: longTitle, id }), 'title')).toBe(true);
|
||||
expect(isValidSharedLinksCursor('a'.repeat(8193), 'title')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildShareFileEtag', () => {
|
||||
const file = {
|
||||
file_id: 'file-1',
|
||||
previewRevision: 'rev-1',
|
||||
bytes: 1234,
|
||||
storageKey: 'uploads/user/plot.png',
|
||||
filepath: '/images/user/plot.png?v=1',
|
||||
};
|
||||
|
||||
it('is stable for an unchanged snapshot', () => {
|
||||
expect(buildShareFileEtag(file)).toBe(buildShareFileEtag({ ...file }));
|
||||
expect(buildShareFileEtag(file)).toMatch(/^"share-[0-9a-f]{32}"$/);
|
||||
});
|
||||
|
||||
it('moves when any part of the snapshot identity moves', () => {
|
||||
const base = buildShareFileEtag(file);
|
||||
expect(buildShareFileEtag({ ...file, previewRevision: 'rev-2' })).not.toBe(base);
|
||||
expect(buildShareFileEtag({ ...file, bytes: 1235 })).not.toBe(base);
|
||||
expect(buildShareFileEtag({ ...file, storageKey: 'uploads/user/other.png' })).not.toBe(base);
|
||||
// A same-size republish keeps its size and revision but moves the stored object.
|
||||
expect(buildShareFileEtag({ ...file, filepath: '/images/user/plot.png?v=2' })).not.toBe(base);
|
||||
});
|
||||
|
||||
it('does not collide when a value shifts across fields', () => {
|
||||
expect(buildShareFileEtag({ file_id: 'a', storageKey: 'b' })).not.toBe(
|
||||
buildShareFileEtag({ file_id: 'ab' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
82
packages/api/src/shared-links/http.ts
Normal file
82
packages/api/src/shared-links/http.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { createHash } from 'crypto';
|
||||
|
||||
export const DEFAULT_SHARED_LINKS_PAGE_SIZE: number = 10;
|
||||
export const MAX_SHARED_LINKS_PAGE_SIZE: number = 100;
|
||||
export const MAX_SHARED_LINK_SEARCH_LENGTH: number = 256;
|
||||
/* A title cursor carries the boundary row's title, and a conversation title can run to
|
||||
about a thousand characters (more once multi-byte). The cap is only here to reject
|
||||
absurd input, so it sits well clear of anything the server can issue. */
|
||||
export const MAX_SHARED_LINK_CURSOR_LENGTH: number = 8192;
|
||||
|
||||
/** Clamp a requested page size, falling back to the default for anything unparseable. */
|
||||
export function parseSharedLinksPageSize(value: unknown): number {
|
||||
if (typeof value !== 'string' || !/^-?\d+$/.test(value)) {
|
||||
return DEFAULT_SHARED_LINKS_PAGE_SIZE;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
return DEFAULT_SHARED_LINKS_PAGE_SIZE;
|
||||
}
|
||||
|
||||
return Math.min(MAX_SHARED_LINKS_PAGE_SIZE, Math.max(1, parsed));
|
||||
}
|
||||
|
||||
/**
|
||||
* The list cursor is opaque: base64 `{ primary, id }`, where `id` is the `_id` that
|
||||
* breaks ties on a repeated title or timestamp and `primary` is null for a row with no
|
||||
* title. Cursors issued before that encoding carry the bare sort value, so a plain date
|
||||
* still passes for a `createdAt` page.
|
||||
*/
|
||||
export function isValidSharedLinksCursor(cursor: string, sortBy: string): boolean {
|
||||
if (cursor.length > MAX_SHARED_LINK_CURSOR_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString());
|
||||
const primary = decoded?.primary;
|
||||
if (
|
||||
(typeof primary === 'string' || primary === null) &&
|
||||
/^[a-f\d]{24}$/i.test(decoded?.id ?? '')
|
||||
) {
|
||||
// A createdAt page can never carry a null boundary: the field is always stamped.
|
||||
if (primary === null) {
|
||||
return sortBy !== 'createdAt';
|
||||
}
|
||||
return sortBy !== 'createdAt' || !Number.isNaN(Date.parse(primary));
|
||||
}
|
||||
} catch {
|
||||
/* Not a composite cursor; fall through to the legacy plain-value check. */
|
||||
}
|
||||
|
||||
return sortBy !== 'createdAt' || !Number.isNaN(Date.parse(cursor));
|
||||
}
|
||||
|
||||
export interface ShareFileEtagSource {
|
||||
file_id: string;
|
||||
previewRevision?: string | number | null;
|
||||
bytes?: number | null;
|
||||
storageKey?: string | null;
|
||||
filepath?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache validator for a snapshotted file. Beyond the fields the share routes pin the
|
||||
* snapshot on (`previewRevision`, `bytes`), it folds in the stored location: a re-publish
|
||||
* that swaps the object without changing size or revision still moves
|
||||
* `storageKey`/`filepath` (regenerated code outputs carry a `?v=` cache-buster), so the
|
||||
* viewer revalidates instead of keeping its stale copy. A same-path, same-size,
|
||||
* same-revision replacement stays the best-effort gap inherent to the no-byte-copy
|
||||
* snapshot design.
|
||||
*/
|
||||
export function buildShareFileEtag(file: ShareFileEtagSource): string {
|
||||
const identity = [
|
||||
file.file_id,
|
||||
file.previewRevision ?? '',
|
||||
file.bytes ?? '',
|
||||
file.storageKey ?? '',
|
||||
file.filepath ?? '',
|
||||
].join('\u0000');
|
||||
return `"share-${createHash('sha256').update(identity).digest('hex').slice(0, 32)}"`;
|
||||
}
|
||||
|
|
@ -265,6 +265,30 @@ describe('ensureLinkPermissions', () => {
|
|||
expect(entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('does not write when the owner grant is already in place', async () => {
|
||||
const link = await createTestLink();
|
||||
await ensureLinkPermissions(link._id, userId);
|
||||
const [granted] = await AclEntry.find({ resourceId: link._id }).lean();
|
||||
|
||||
await ensureLinkPermissions(link._id, userId);
|
||||
|
||||
const [after] = await AclEntry.find({ resourceId: link._id }).lean();
|
||||
// Rendering the badge must not keep re-granting: `grantedAt` has to stand still.
|
||||
expect(after.grantedAt?.toISOString()).toBe(granted.grantedAt?.toISOString());
|
||||
});
|
||||
|
||||
test('still runs the legacy migration when the owner grant already exists', async () => {
|
||||
const link = await createTestLink();
|
||||
await ensureLinkPermissions(link._id, userId);
|
||||
// A legacy row keeps its marker until every grant it needs exists.
|
||||
await SharedLink.updateOne({ _id: link._id }, { $set: { isPublic: true } });
|
||||
|
||||
await ensureLinkPermissions(link._id, userId);
|
||||
|
||||
const migrated = await SharedLink.findById(link._id).lean();
|
||||
expect((migrated as { isPublic?: boolean } | null)?.isPublic).toBeUndefined();
|
||||
});
|
||||
|
||||
test('does not delete the SharedLink on failure', async () => {
|
||||
const link = await createTestLink();
|
||||
const AccessRole = mongoose.models.AccessRole;
|
||||
|
|
|
|||
|
|
@ -188,11 +188,29 @@ export async function ensureLinkPermissions(
|
|||
return;
|
||||
}
|
||||
|
||||
/* A legacy row keeps its marker until every grant it needs exists, so it always goes
|
||||
* back through the migration: the owner grant alone does not mean the public one was
|
||||
* ever created. `autoMigrateLegacyLink` skips the grants that are already in place. */
|
||||
if ('isPublic' in rawDoc) {
|
||||
await autoMigrateLegacyLink(rawDoc as Parameters<typeof autoMigrateLegacyLink>[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* The share badge reads the link on ordinary navigation, so the settled path has to
|
||||
* stay read-only: re-granting on every open would turn a page load into an ACL write
|
||||
* and reset `grantedAt`. Only a link that still has no owner grant needs one.
|
||||
*/
|
||||
const alreadyGranted = await getAclService().checkPermission({
|
||||
userId,
|
||||
resourceType: ResourceType.SHARED_LINK,
|
||||
resourceId: sharedLinkId.toString(),
|
||||
requiredPermission: PermissionBits.DELETE,
|
||||
});
|
||||
if (alreadyGranted) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await getAclService().grantPermission({
|
||||
principalType: PrincipalType.USER,
|
||||
|
|
|
|||
|
|
@ -576,13 +576,13 @@ describe('DataTable', () => {
|
|||
</TestWrapper>,
|
||||
);
|
||||
|
||||
const sortableHeader = screen.getAllByTestId('table-head')[1]; // Skip select column
|
||||
fireEvent.click(sortableHeader);
|
||||
const sortButton = screen.getByRole('button', { name: 'Name' });
|
||||
fireEvent.click(sortButton);
|
||||
|
||||
expect(mockOnSortingChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should trigger sort on Enter key', () => {
|
||||
it('should toggle direction rather than clearing the sort', () => {
|
||||
const mockOnSortingChange = jest.fn();
|
||||
const columns: TableColumn<TestData, string>[] = [
|
||||
{
|
||||
|
|
@ -591,51 +591,47 @@ describe('DataTable', () => {
|
|||
enableSorting: true,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={createTestData(5)}
|
||||
sorting={[{ id: 'name', desc: true }]}
|
||||
onSortingChange={mockOnSortingChange}
|
||||
/>
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Name' }));
|
||||
|
||||
const updater = mockOnSortingChange.mock.calls[0][0];
|
||||
const next =
|
||||
typeof updater === 'function'
|
||||
? updater([{ id: 'name', desc: true }] as SortingState)
|
||||
: updater;
|
||||
expect(next).toEqual([{ id: 'name', desc: false }]);
|
||||
});
|
||||
|
||||
it('should expose sorting through a native button', () => {
|
||||
const columns: TableColumn<TestData, string>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
enableSorting: true,
|
||||
},
|
||||
];
|
||||
const data = createTestData(5);
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
sorting={[]}
|
||||
onSortingChange={mockOnSortingChange}
|
||||
/>
|
||||
<DataTable columns={columns} data={data} sorting={[]} onSortingChange={jest.fn()} />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
const sortableHeader = screen.getAllByTestId('table-head')[1];
|
||||
fireEvent.keyDown(sortableHeader, { key: 'Enter' });
|
||||
|
||||
expect(mockOnSortingChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should trigger sort on Space key', () => {
|
||||
const mockOnSortingChange = jest.fn();
|
||||
const columns: TableColumn<TestData, string>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
enableSorting: true,
|
||||
},
|
||||
];
|
||||
const data = createTestData(5);
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
sorting={[]}
|
||||
onSortingChange={mockOnSortingChange}
|
||||
/>
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
const sortableHeader = screen.getAllByTestId('table-head')[1];
|
||||
fireEvent.keyDown(sortableHeader, { key: ' ' });
|
||||
|
||||
expect(mockOnSortingChange).toHaveBeenCalled();
|
||||
const sortButton = screen.getByRole('button', { name: 'Name' });
|
||||
expect(sortButton.tagName).toBe('BUTTON');
|
||||
expect(sortButton).toHaveAttribute('type', 'button');
|
||||
});
|
||||
|
||||
it('should show ascending icon when sorted ascending', () => {
|
||||
|
|
@ -694,8 +690,8 @@ describe('DataTable', () => {
|
|||
</TestWrapper>,
|
||||
);
|
||||
|
||||
const sortableHeader = screen.getAllByTestId('table-head')[1];
|
||||
fireEvent.click(sortableHeader);
|
||||
const sortButton = screen.getByRole('button', { name: 'Name' });
|
||||
fireEvent.click(sortButton);
|
||||
|
||||
// Should show ascending icon after click
|
||||
expect(screen.getByTestId('arrow-up')).toBeInTheDocument();
|
||||
|
|
@ -846,6 +842,295 @@ describe('DataTable', () => {
|
|||
|
||||
expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not paginate on scroll while the replacement page is loading', () => {
|
||||
const fetchNextPage = jest.fn().mockResolvedValue(undefined);
|
||||
const clientHeight = jest
|
||||
.spyOn(HTMLElement.prototype, 'clientHeight', 'get')
|
||||
.mockReturnValue(600);
|
||||
const scrollHeight = jest
|
||||
.spyOn(HTMLElement.prototype, 'scrollHeight', 'get')
|
||||
.mockReturnValue(700);
|
||||
|
||||
const { container } = render(
|
||||
<TestWrapper>
|
||||
<DataTable
|
||||
columns={createTestColumns()}
|
||||
data={createTestData(30)}
|
||||
hasNextPage={true}
|
||||
isFetching={true}
|
||||
isFetchingNextPage={false}
|
||||
fetchNextPage={fetchNextPage}
|
||||
/>
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
const scrollArea = container.querySelector(
|
||||
'[aria-label="com_ui_data_table_scroll_area"]',
|
||||
) as HTMLElement;
|
||||
fireEvent.scroll(scrollArea);
|
||||
jest.advanceTimersByTime(200);
|
||||
|
||||
expect(fetchNextPage).not.toHaveBeenCalled();
|
||||
clientHeight.mockRestore();
|
||||
scrollHeight.mockRestore();
|
||||
});
|
||||
|
||||
describe('auto-fill when the first page cannot scroll', () => {
|
||||
const stubLayout = ({
|
||||
clientHeight,
|
||||
scrollHeight,
|
||||
}: {
|
||||
clientHeight: number;
|
||||
scrollHeight: number;
|
||||
}) => {
|
||||
jest.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(clientHeight);
|
||||
jest.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(scrollHeight);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('fetches the next page when the rows do not overflow the container', () => {
|
||||
stubLayout({ clientHeight: 600, scrollHeight: 300 });
|
||||
const fetchNextPage = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<DataTable
|
||||
columns={createTestColumns()}
|
||||
data={createTestData(3)}
|
||||
hasNextPage={true}
|
||||
isFetchingNextPage={false}
|
||||
fetchNextPage={fetchNextPage}
|
||||
/>
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
expect(fetchNextPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('leaves pagination to the scroll handler once the rows overflow', () => {
|
||||
stubLayout({ clientHeight: 600, scrollHeight: 1200 });
|
||||
const fetchNextPage = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<DataTable
|
||||
columns={createTestColumns()}
|
||||
data={createTestData(30)}
|
||||
hasNextPage={true}
|
||||
isFetchingNextPage={false}
|
||||
fetchNextPage={fetchNextPage}
|
||||
/>
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
expect(fetchNextPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fetch while the container is unmeasured', () => {
|
||||
stubLayout({ clientHeight: 0, scrollHeight: 0 });
|
||||
const fetchNextPage = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<DataTable
|
||||
columns={createTestColumns()}
|
||||
data={createTestData(3)}
|
||||
hasNextPage={true}
|
||||
isFetchingNextPage={false}
|
||||
fetchNextPage={fetchNextPage}
|
||||
/>
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
expect(fetchNextPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries a rejected fetch, then gives up instead of hammering', async () => {
|
||||
stubLayout({ clientHeight: 600, scrollHeight: 300 });
|
||||
const fetchNextPage = jest.fn().mockRejectedValue(new Error('offline'));
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<DataTable
|
||||
columns={createTestColumns()}
|
||||
data={createTestData(3)}
|
||||
hasNextPage={true}
|
||||
isFetchingNextPage={false}
|
||||
fetchNextPage={fetchNextPage}
|
||||
/>
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(fetchNextPage).toHaveBeenCalledTimes(3));
|
||||
await Promise.resolve();
|
||||
expect(fetchNextPage).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('re-arms when the sort changes under a same-sized page', () => {
|
||||
stubLayout({ clientHeight: 600, scrollHeight: 300 });
|
||||
const fetchNextPage = jest.fn().mockResolvedValue(undefined);
|
||||
const props = {
|
||||
columns: createTestColumns(),
|
||||
data: createTestData(3),
|
||||
hasNextPage: true,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage,
|
||||
onSortingChange: jest.fn(),
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<TestWrapper>
|
||||
<DataTable {...props} sorting={[{ id: 'name', desc: false }]} />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
expect(fetchNextPage).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<TestWrapper>
|
||||
<DataTable {...props} sorting={[{ id: 'name', desc: true }]} />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
expect(fetchNextPage).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('sends the viewport back to the top when the sort changes', () => {
|
||||
stubLayout({ clientHeight: 600, scrollHeight: 1200 });
|
||||
const props = {
|
||||
columns: createTestColumns(),
|
||||
data: createTestData(30),
|
||||
onSortingChange: jest.fn(),
|
||||
};
|
||||
|
||||
const { rerender, container } = render(
|
||||
<TestWrapper>
|
||||
<DataTable {...props} sorting={[{ id: 'name', desc: false }]} />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
const scrollArea = container.querySelector('[aria-label="com_ui_data_table_scroll_area"]');
|
||||
expect(scrollArea).not.toBeNull();
|
||||
(scrollArea as HTMLElement).scrollTop = 400;
|
||||
|
||||
rerender(
|
||||
<TestWrapper>
|
||||
<DataTable {...props} sorting={[{ id: 'name', desc: true }]} />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
expect((scrollArea as HTMLElement).scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
it('retries when the fetch resolves with a failed result', async () => {
|
||||
stubLayout({ clientHeight: 600, scrollHeight: 300 });
|
||||
// React Query hands back a failed result instead of rejecting.
|
||||
const fetchNextPage = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ isError: true, error: new Error('offline') });
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<DataTable
|
||||
columns={createTestColumns()}
|
||||
data={createTestData(3)}
|
||||
hasNextPage={true}
|
||||
isFetchingNextPage={false}
|
||||
fetchNextPage={fetchNextPage}
|
||||
/>
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(fetchNextPage).toHaveBeenCalledTimes(3));
|
||||
await Promise.resolve();
|
||||
expect(fetchNextPage).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('waits for the replacement query instead of racing it', () => {
|
||||
stubLayout({ clientHeight: 600, scrollHeight: 300 });
|
||||
const fetchNextPage = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<DataTable
|
||||
columns={createTestColumns()}
|
||||
data={createTestData(3)}
|
||||
hasNextPage={true}
|
||||
isFetching={true}
|
||||
isFetchingNextPage={false}
|
||||
fetchNextPage={fetchNextPage}
|
||||
/>
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
expect(fetchNextPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops after a page that adds no rows', () => {
|
||||
stubLayout({ clientHeight: 600, scrollHeight: 300 });
|
||||
const fetchNextPage = jest.fn().mockResolvedValue(undefined);
|
||||
const props = {
|
||||
columns: createTestColumns(),
|
||||
data: createTestData(3),
|
||||
hasNextPage: true,
|
||||
fetchNextPage,
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<TestWrapper>
|
||||
<DataTable {...props} isFetchingNextPage={false} />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<TestWrapper>
|
||||
<DataTable {...props} isFetchingNextPage={true} />
|
||||
</TestWrapper>,
|
||||
);
|
||||
rerender(
|
||||
<TestWrapper>
|
||||
<DataTable {...props} isFetchingNextPage={false} />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
expect(fetchNextPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Row memoization', () => {
|
||||
it('re-renders rows when the column definitions change', () => {
|
||||
const makeColumns = (label: string): TableColumn<TestData, string>[] => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
cell: () => <span data-testid="action-cell">{label}</span>,
|
||||
},
|
||||
];
|
||||
// The same rows: only the cells change, which is what a pending row action
|
||||
// looks like to the memo comparator.
|
||||
const data = createTestData(3);
|
||||
|
||||
const { rerender } = render(
|
||||
<TestWrapper>
|
||||
<DataTable columns={makeColumns('Restore')} data={data} />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByTestId('action-cell')[0]).toHaveTextContent('Restore');
|
||||
|
||||
rerender(
|
||||
<TestWrapper>
|
||||
<DataTable columns={makeColumns('Restoring')} data={data} />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByTestId('action-cell')[0]).toHaveTextContent('Restoring');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom Actions', () => {
|
||||
|
|
@ -978,7 +1263,7 @@ describe('DataTable', () => {
|
|||
expect(table).toHaveAttribute('aria-label', 'com_ui_data_table');
|
||||
});
|
||||
|
||||
it('should have proper role on sortable headers', () => {
|
||||
it('should preserve column-header semantics and use a nested sort button', () => {
|
||||
const columns: TableColumn<TestData, string>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
|
|
@ -995,8 +1280,9 @@ describe('DataTable', () => {
|
|||
);
|
||||
|
||||
const sortableHeader = screen.getAllByTestId('table-head')[1];
|
||||
expect(sortableHeader).toHaveAttribute('role', 'button');
|
||||
expect(sortableHeader).toHaveAttribute('tabIndex', '0');
|
||||
expect(sortableHeader).toHaveAttribute('scope', 'col');
|
||||
expect(sortableHeader).not.toHaveAttribute('role', 'button');
|
||||
expect(screen.getByRole('button', { name: 'Name' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,12 +19,21 @@ import { useDebounced, useOptimizedRowSelection } from './DataTable.hooks';
|
|||
import { useMediaQuery, useLocalize } from '~/hooks';
|
||||
import { DataTableSearch } from './DataTableSearch';
|
||||
import { cn, logger } from '~/utils';
|
||||
import { Button } from '../Button';
|
||||
import { Label } from '../Label';
|
||||
import { Spinner } from '~/svgs';
|
||||
|
||||
const MAX_AUTO_FILL_ATTEMPTS = 3;
|
||||
|
||||
const isFailedFetchResult = (result: unknown): result is { isError: true; error?: unknown } =>
|
||||
typeof result === 'object' &&
|
||||
result !== null &&
|
||||
(result as { isError?: unknown }).isError === true;
|
||||
|
||||
function DataTable<TData extends Record<string, unknown>, TValue>({
|
||||
columns,
|
||||
data,
|
||||
getRowId: getRowIdProp,
|
||||
className = '',
|
||||
isLoading = false,
|
||||
isFetching = false,
|
||||
|
|
@ -65,6 +74,17 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
|
|||
const lastScrollTopRef = useRef(0);
|
||||
const lastScrollTimeRef = useRef(performance.now());
|
||||
const fastScrollTimeoutRef = useRef<number | null>(null);
|
||||
const autoFillRowCountRef = useRef(-1);
|
||||
/* Column defs are rebuilt when a consumer's row actions change state (a pending
|
||||
restore, say). Memoized rows compare row data, which has not moved, so they need
|
||||
this marker to know their cells were redefined. */
|
||||
const cellsVersionRef = useRef(0);
|
||||
const renderedColumnsRef = useRef(columns);
|
||||
if (renderedColumnsRef.current !== columns) {
|
||||
renderedColumnsRef.current = columns;
|
||||
cellsVersionRef.current += 1;
|
||||
}
|
||||
const [autoFillAttempt, setAutoFillAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setDynamicOverscan(overscan);
|
||||
|
|
@ -91,8 +111,9 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
|
|||
const isIndeterminate = selectedCount > 0 && !isAllSelected;
|
||||
|
||||
const getRowId = useCallback(
|
||||
(row: TData, index?: number) => String(row.id ?? `row-${index ?? 0}`),
|
||||
[],
|
||||
(row: TData, index?: number) =>
|
||||
getRowIdProp?.(row, index ?? 0) ?? String(row.id ?? row._id ?? `row-${index ?? 0}`),
|
||||
[getRowIdProp],
|
||||
);
|
||||
|
||||
const selectedRows = useMemo(() => {
|
||||
|
|
@ -117,6 +138,10 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
|
|||
|
||||
const debouncedTerm = useDebounced(searchTerm, debounceDelay);
|
||||
const finalSorting = sorting ?? internalSorting;
|
||||
const sortKey = useMemo(
|
||||
() => finalSorting.map((sort) => `${sort.id}:${sort.desc ? 'desc' : 'asc'}`).join(','),
|
||||
[finalSorting],
|
||||
);
|
||||
|
||||
// Mobile column visibility: columns with desktopOnly meta are hidden via CSS on mobile
|
||||
// but remain in DOM for accessibility. CSS classes handle visual hiding.
|
||||
|
|
@ -155,8 +180,12 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
|
|||
const hasWarnedAboutMissingIds = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (data.length > 0 && !hasWarnedAboutMissingIds.current) {
|
||||
const missing = data.filter((item) => item.id === null || item.id === undefined);
|
||||
if (data.length > 0 && !getRowIdProp && !hasWarnedAboutMissingIds.current) {
|
||||
const missing = data.filter(
|
||||
(item) =>
|
||||
(item.id === null || item.id === undefined) &&
|
||||
(item._id === null || item._id === undefined),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
logger.warn(
|
||||
`DataTable Warning: ${missing.length} data rows are missing a unique "id" property. Using index as a fallback. This can lead to unexpected behavior with selection and sorting.`,
|
||||
|
|
@ -165,7 +194,7 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
|
|||
hasWarnedAboutMissingIds.current = true;
|
||||
}
|
||||
}
|
||||
}, [data]);
|
||||
}, [data, getRowIdProp]);
|
||||
|
||||
const tableColumns = useMemo((): ColumnDef<TData, TValue>[] => {
|
||||
if (!enableRowSelection || !showCheckboxes) {
|
||||
|
|
@ -209,12 +238,7 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
|
|||
? `named ${row.original.name}`
|
||||
: `at position ${row.index + 1}`;
|
||||
return (
|
||||
<div
|
||||
className="flex h-full items-center justify-center"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={localize(`com_ui_select_row`, { 0: rowDescription })}
|
||||
>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<SelectionCheckbox
|
||||
checked={row.getIsSelected()}
|
||||
onChange={(value) => row.toggleSelected(value)}
|
||||
|
|
@ -252,6 +276,10 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
|
|||
enableMultiRowSelection: true,
|
||||
manualSorting: true,
|
||||
manualFiltering: true,
|
||||
/* Header clicks toggle direction instead of cycling through "unsorted". A
|
||||
server-paginated table always sorts by something, so the removal step
|
||||
reads as a dead click and leaves one direction unreachable. */
|
||||
enableSortingRemoval: false,
|
||||
state: {
|
||||
sorting: finalSorting,
|
||||
columnVisibility,
|
||||
|
|
@ -315,6 +343,7 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
|
|||
row={row as unknown as Row<Record<string, unknown>>}
|
||||
virtualIndex={virtualRow.index}
|
||||
selected={row.getIsSelected()}
|
||||
cellsVersion={cellsVersionRef.current}
|
||||
style={{ height: rowHeight }}
|
||||
/>
|
||||
);
|
||||
|
|
@ -336,6 +365,7 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
|
|||
row={row as unknown as Row<Record<string, unknown>>}
|
||||
virtualIndex={row.index}
|
||||
selected={row.getIsSelected()}
|
||||
cellsVersion={cellsVersionRef.current}
|
||||
style={{ height: rowHeight }}
|
||||
/>
|
||||
));
|
||||
|
|
@ -345,6 +375,19 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
|
|||
setSearchTerm(filterValue);
|
||||
}, [filterValue]);
|
||||
|
||||
/* A new search or sort replaces the rows with a fresh first page, which can
|
||||
land on the same count the auto-fill guard already recorded. Clear it so a
|
||||
still-unscrollable page keeps paging, and send the viewport back to the top:
|
||||
the query keeps the previous rows while it refetches, so the container would
|
||||
otherwise stay parked mid-list over an unrelated result set. */
|
||||
useEffect(() => {
|
||||
autoFillRowCountRef.current = -1;
|
||||
setAutoFillAttempt(0);
|
||||
if (tableContainerRef.current) {
|
||||
tableContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [filterValue, sortKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedTerm !== filterValue && onFilterChange) {
|
||||
onFilterChange(debouncedTerm);
|
||||
|
|
@ -370,53 +413,64 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
|
|||
return () => ro.disconnect();
|
||||
}, [virtualizationActive, rowVirtualizer]);
|
||||
|
||||
const handleScroll = useMemo(() => {
|
||||
let rafId: number | null = null;
|
||||
let timeoutId: number | null = null;
|
||||
const handleScroll = useCallback(() => {
|
||||
if (scrollRAFRef.current) cancelAnimationFrame(scrollRAFRef.current);
|
||||
|
||||
return () => {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const container = tableContainerRef.current;
|
||||
if (container) {
|
||||
const now = performance.now();
|
||||
const delta = Math.abs(container.scrollTop - lastScrollTopRef.current);
|
||||
const dt = now - lastScrollTimeRef.current;
|
||||
if (dt > 0) {
|
||||
const velocity = delta / dt;
|
||||
// Increase overscan during fast scrolling for smoother experience
|
||||
if (velocity > 2 && virtualizationActive && dynamicOverscan === overscan) {
|
||||
if (fastScrollTimeoutRef.current) {
|
||||
window.clearTimeout(fastScrollTimeoutRef.current);
|
||||
}
|
||||
setDynamicOverscan(Math.min(overscan * fastOverscanMultiplier, overscan * 8));
|
||||
fastScrollTimeoutRef.current = window.setTimeout(() => {
|
||||
setDynamicOverscan((current) => (current !== overscan ? overscan : current));
|
||||
}, 160);
|
||||
scrollRAFRef.current = requestAnimationFrame(() => {
|
||||
const container = tableContainerRef.current;
|
||||
if (container) {
|
||||
const now = performance.now();
|
||||
const delta = Math.abs(container.scrollTop - lastScrollTopRef.current);
|
||||
const dt = now - lastScrollTimeRef.current;
|
||||
if (dt > 0) {
|
||||
const velocity = delta / dt;
|
||||
// Increase overscan during fast scrolling for smoother experience
|
||||
if (velocity > 2 && virtualizationActive && dynamicOverscan === overscan) {
|
||||
if (fastScrollTimeoutRef.current) {
|
||||
window.clearTimeout(fastScrollTimeoutRef.current);
|
||||
}
|
||||
setDynamicOverscan(Math.min(overscan * fastOverscanMultiplier, overscan * 8));
|
||||
fastScrollTimeoutRef.current = window.setTimeout(() => {
|
||||
setDynamicOverscan((current) => (current !== overscan ? overscan : current));
|
||||
}, 160);
|
||||
}
|
||||
lastScrollTopRef.current = container.scrollTop;
|
||||
lastScrollTimeRef.current = now;
|
||||
}
|
||||
lastScrollTopRef.current = container.scrollTop;
|
||||
lastScrollTimeRef.current = now;
|
||||
}
|
||||
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current);
|
||||
|
||||
// Trigger infinite scroll pagination
|
||||
timeoutId = window.setTimeout(() => {
|
||||
const loaderContainer = tableContainerRef.current;
|
||||
if (!loaderContainer || !fetchNextPage || !hasNextPage || isFetchingNextPage) return;
|
||||
// Trigger infinite scroll pagination
|
||||
scrollTimeoutRef.current = window.setTimeout(() => {
|
||||
const loaderContainer = tableContainerRef.current;
|
||||
// `isFetching`: a search or sort swap scrolls the viewport back to the top while
|
||||
// the replacement page is still loading, and this handler must not answer that
|
||||
// programmatic scroll with a competing fetch on the same infinite query.
|
||||
if (!loaderContainer || !fetchNextPage || !hasNextPage || isFetchingNextPage || isFetching)
|
||||
return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = loaderContainer;
|
||||
if (scrollTop + clientHeight >= scrollHeight - 200) {
|
||||
fetchNextPage().finally();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
const { scrollTop, scrollHeight, clientHeight } = loaderContainer;
|
||||
if (scrollTop + clientHeight >= scrollHeight - 200) {
|
||||
// Resolves with a failed result rather than rejecting, so both shapes count.
|
||||
void fetchNextPage()
|
||||
.then((result) => {
|
||||
if (isFailedFetchResult(result)) {
|
||||
logger.error('DataTable: Unable to fetch the next page', result.error);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('DataTable: Unable to fetch the next page', error);
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
|
||||
scrollRAFRef.current = null;
|
||||
});
|
||||
}, [
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
overscan,
|
||||
fastOverscanMultiplier,
|
||||
|
|
@ -435,6 +489,63 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
|
|||
};
|
||||
}, [handleScroll, cleanupTimers]);
|
||||
|
||||
/**
|
||||
* Pagination is driven by the scroll handler, so a first page too short to
|
||||
* overflow a tall container would strand the table on page one. Keep pulling
|
||||
* pages until the rows overflow or the source runs dry; the row-count guard
|
||||
* stops the loop when a page adds nothing. A rejected fetch is retried, since
|
||||
* an unscrollable table offers no other way back, but only a bounded number of
|
||||
* times so a failing endpoint can't be hammered.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const container = tableContainerRef.current;
|
||||
if (!container || !fetchNextPage || !hasNextPage || isFetchingNextPage || isLoading) {
|
||||
return;
|
||||
}
|
||||
/* A search or sort swap keeps the previous rows on screen while the replacement
|
||||
first page is in flight, and an infinite query runs one fetch at a time, so
|
||||
asking for page two now would fight the request that is already out. */
|
||||
if (isFetching) {
|
||||
return;
|
||||
}
|
||||
if (autoFillAttempt >= MAX_AUTO_FILL_ATTEMPTS) {
|
||||
return;
|
||||
}
|
||||
if (container.clientHeight === 0 || container.scrollHeight > container.clientHeight) {
|
||||
return;
|
||||
}
|
||||
if (autoFillRowCountRef.current === data.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
autoFillRowCountRef.current = data.length;
|
||||
const rearmAfterFailure = (error?: unknown) => {
|
||||
logger.error('DataTable: Unable to fetch the next page', error);
|
||||
autoFillRowCountRef.current = -1;
|
||||
setAutoFillAttempt((attempt) => attempt + 1);
|
||||
};
|
||||
|
||||
/* React Query resolves `fetchNextPage` with a failed result rather than rejecting,
|
||||
so a rejection handler alone would leave the guard armed on the unchanged row
|
||||
count and strand the table on this page. */
|
||||
void fetchNextPage()
|
||||
.then((result) => {
|
||||
if (isFailedFetchResult(result)) {
|
||||
rearmAfterFailure(result.error);
|
||||
}
|
||||
})
|
||||
.catch(rearmAfterFailure);
|
||||
}, [
|
||||
data.length,
|
||||
sortKey,
|
||||
autoFillAttempt,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -490,31 +601,6 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
|
|||
const meta = header.column.columnDef.meta as { className?: string } | undefined;
|
||||
const canSort = header.column.getCanSort();
|
||||
|
||||
let sortAriaLabel: string | undefined;
|
||||
if (canSort) {
|
||||
const sortState = header.column.getIsSorted();
|
||||
let sortStateLabel = 'sortable';
|
||||
if (sortState === 'asc') {
|
||||
sortStateLabel = 'ascending';
|
||||
} else if (sortState === 'desc') {
|
||||
sortStateLabel = 'descending';
|
||||
}
|
||||
|
||||
const headerLabel =
|
||||
typeof header.column.columnDef.header === 'string'
|
||||
? header.column.columnDef.header
|
||||
: header.column.id;
|
||||
|
||||
sortAriaLabel = `${headerLabel ?? ''} column, ${sortStateLabel}`;
|
||||
}
|
||||
|
||||
const handleSortingKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (canSort && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
header.column.toggleSorting();
|
||||
}
|
||||
};
|
||||
|
||||
const metaWidth = (header.column.columnDef.meta as { width?: number } | undefined)
|
||||
?.width;
|
||||
let widthStyle: React.CSSProperties = {};
|
||||
|
|
@ -529,12 +615,44 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
|
|||
}
|
||||
|
||||
const sortDirection = header.column.getIsSorted();
|
||||
let ariaSort: 'ascending' | 'descending' | undefined;
|
||||
let ariaSort: 'ascending' | 'descending' | 'none' | undefined;
|
||||
if (sortDirection === 'asc') {
|
||||
ariaSort = 'ascending';
|
||||
} else if (sortDirection === 'desc') {
|
||||
ariaSort = 'descending';
|
||||
} else if (canSort) {
|
||||
ariaSort = 'none';
|
||||
}
|
||||
|
||||
const renderedHeader = header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext());
|
||||
let headerContent: React.ReactNode;
|
||||
if (isSelectHeader) {
|
||||
headerContent = renderedHeader;
|
||||
} else if (canSort) {
|
||||
headerContent = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-auto w-full justify-start gap-1 px-0 py-0 font-medium hover:bg-transparent md:gap-2"
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
{renderedHeader}
|
||||
<span className="text-text-primary" aria-hidden="true">
|
||||
{{
|
||||
asc: <ArrowUp className="size-4 text-text-primary" />,
|
||||
desc: <ArrowDown className="size-4 text-text-primary" />,
|
||||
}[header.column.getIsSorted() as string] ?? (
|
||||
<ArrowDownUp className="size-4 text-text-primary" />
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
} else {
|
||||
headerContent = <div className="flex items-center">{renderedHeader}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
|
|
@ -548,30 +666,9 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
|
|||
isDesktopOnly && 'hidden md:table-cell',
|
||||
)}
|
||||
style={widthStyle}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
onKeyDown={handleSortingKeyDown}
|
||||
role={canSort ? 'button' : undefined}
|
||||
tabIndex={canSort ? 0 : undefined}
|
||||
aria-label={sortAriaLabel}
|
||||
aria-sort={ariaSort}
|
||||
>
|
||||
{isSelectHeader ? (
|
||||
flexRender(header.column.columnDef.header, header.getContext())
|
||||
) : (
|
||||
<div className="flex items-center gap-1 md:gap-2">
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{canSort && (
|
||||
<span className="text-text-primary" aria-hidden="true">
|
||||
{{
|
||||
asc: <ArrowUp className="size-4 text-text-primary" />,
|
||||
desc: <ArrowDown className="size-4 text-text-primary" />,
|
||||
}[header.column.getIsSorted() as string] ?? (
|
||||
<ArrowDownUp className="size-4 text-text-primary" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{headerContent}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ export interface DataTableConfig {
|
|||
export interface DataTableProps<TData extends Record<string, unknown>, TValue> {
|
||||
columns: TableColumn<TData, TValue>[];
|
||||
data: TData[];
|
||||
getRowId?: (row: TData, index: number) => string;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
isFetching?: boolean;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { SelectionCheckbox, SkeletonRows } from './DataTableComponents';
|
||||
import type { TableColumn } from './DataTable.types';
|
||||
import { SelectionCheckbox, SkeletonRows } from './DataTableComponents';
|
||||
|
||||
// Mock the cn utility
|
||||
jest.mock('~/utils', () => ({
|
||||
|
|
@ -121,8 +121,8 @@ describe('DataTableComponents', () => {
|
|||
const mockOnChange = jest.fn();
|
||||
render(<SelectionCheckbox checked={false} onChange={mockOnChange} ariaLabel="Select row" />);
|
||||
|
||||
const wrapper = screen.getByRole('button');
|
||||
fireEvent.click(wrapper);
|
||||
const checkbox = screen.getByRole('checkbox');
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
|
@ -131,42 +131,17 @@ describe('DataTableComponents', () => {
|
|||
const mockOnChange = jest.fn();
|
||||
render(<SelectionCheckbox checked={true} onChange={mockOnChange} ariaLabel="Select row" />);
|
||||
|
||||
const wrapper = screen.getByRole('button');
|
||||
fireEvent.click(wrapper);
|
||||
const checkbox = screen.getByRole('checkbox');
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should trigger onChange on Enter key', () => {
|
||||
const mockOnChange = jest.fn();
|
||||
render(<SelectionCheckbox checked={false} onChange={mockOnChange} ariaLabel="Select row" />);
|
||||
it('should expose one native checkbox control', () => {
|
||||
render(<SelectionCheckbox checked={false} onChange={jest.fn()} ariaLabel="Select row" />);
|
||||
|
||||
const wrapper = screen.getByRole('button');
|
||||
fireEvent.keyDown(wrapper, { key: 'Enter' });
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should trigger onChange on Space key', () => {
|
||||
const mockOnChange = jest.fn();
|
||||
render(<SelectionCheckbox checked={false} onChange={mockOnChange} ariaLabel="Select row" />);
|
||||
|
||||
const wrapper = screen.getByRole('button');
|
||||
fireEvent.keyDown(wrapper, { key: ' ' });
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should not trigger onChange on other keys', () => {
|
||||
const mockOnChange = jest.fn();
|
||||
render(<SelectionCheckbox checked={false} onChange={mockOnChange} ariaLabel="Select row" />);
|
||||
|
||||
const wrapper = screen.getByRole('button');
|
||||
fireEvent.keyDown(wrapper, { key: 'a' });
|
||||
fireEvent.keyDown(wrapper, { key: 'Tab' });
|
||||
fireEvent.keyDown(wrapper, { key: 'Escape' });
|
||||
|
||||
expect(mockOnChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('checkbox')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should stop event propagation on click', () => {
|
||||
|
|
@ -179,8 +154,8 @@ describe('DataTableComponents', () => {
|
|||
</div>,
|
||||
);
|
||||
|
||||
const wrapper = screen.getByRole('button');
|
||||
fireEvent.click(wrapper);
|
||||
const checkbox = screen.getByRole('checkbox');
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalled();
|
||||
expect(mockParentClick).not.toHaveBeenCalled();
|
||||
|
|
@ -196,18 +171,18 @@ describe('DataTableComponents', () => {
|
|||
</div>,
|
||||
);
|
||||
|
||||
const wrapper = screen.getByRole('button');
|
||||
fireEvent.keyDown(wrapper, { key: 'Enter' });
|
||||
const checkbox = screen.getByRole('checkbox');
|
||||
fireEvent.keyDown(checkbox, { key: 'Enter' });
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalled();
|
||||
expect(mockOnChange).not.toHaveBeenCalled();
|
||||
expect(mockParentKeyDown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should have tabIndex 0 for keyboard accessibility', () => {
|
||||
it('should remain keyboard focusable', () => {
|
||||
render(<SelectionCheckbox checked={false} onChange={jest.fn()} ariaLabel="Select row" />);
|
||||
|
||||
const wrapper = screen.getByRole('button');
|
||||
expect(wrapper).toHaveAttribute('tabindex', '0');
|
||||
const checkbox = screen.getByRole('checkbox');
|
||||
expect(checkbox).not.toHaveAttribute('tabindex', '-1');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -29,20 +29,9 @@ export const SelectionCheckbox: React.MemoExoticComponent<
|
|||
ariaLabel: string;
|
||||
}): JSX.Element => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onChange(!checked);
|
||||
}
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="flex h-full w-8 items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange(!checked);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox checked={checked} onCheckedChange={onChange} aria-label={ariaLabel} />
|
||||
</div>
|
||||
|
|
@ -63,9 +52,6 @@ const TableRowComponent = <TData extends Record<string, unknown>>(
|
|||
{ row, virtualIndex, style, selected }: TableRowComponentProps<TData>,
|
||||
ref: React.Ref<HTMLTableRowElement>,
|
||||
) => {
|
||||
// Check if we're on mobile - use window.innerWidth for component-level check
|
||||
const isSmallScreen = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
ref={ref}
|
||||
|
|
@ -94,13 +80,6 @@ const TableRowComponent = <TData extends Record<string, unknown>>(
|
|||
|
||||
const CellComponent = isRowHeader ? TableRowHeader : TableCell;
|
||||
|
||||
// For desktop-only columns on mobile, keep them in DOM but visually hidden
|
||||
// This ensures screen readers can still access the content
|
||||
const cellProps =
|
||||
isDesktopOnly && isSmallScreen
|
||||
? { 'aria-hidden': false as const } // Keep accessible to screen readers
|
||||
: {};
|
||||
|
||||
return (
|
||||
<CellComponent
|
||||
key={cell.id}
|
||||
|
|
@ -111,7 +90,6 @@ const TableRowComponent = <TData extends Record<string, unknown>>(
|
|||
isDesktopOnly && 'hidden md:table-cell',
|
||||
)}
|
||||
style={widthStyle}
|
||||
{...cellProps}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</CellComponent>
|
||||
|
|
@ -133,13 +111,19 @@ interface GenericRowProps {
|
|||
virtualIndex?: number;
|
||||
style?: React.CSSProperties;
|
||||
selected: boolean;
|
||||
/** Bumped when the column definitions change. Row data alone can't express a cell
|
||||
* that renders external state (a pending row action, say), so without this the
|
||||
* memo would keep showing the stale cell until the underlying row object moves. */
|
||||
cellsVersion?: number;
|
||||
}
|
||||
|
||||
export const MemoizedTableRow: React.MemoExoticComponent<(props: GenericRowProps) => JSX.Element> =
|
||||
memo(
|
||||
ForwardTableRowComponent as (props: GenericRowProps) => JSX.Element,
|
||||
(prev: GenericRowProps, next: GenericRowProps) =>
|
||||
prev.row.original === next.row.original && prev.selected === next.selected,
|
||||
prev.row.original === next.row.original &&
|
||||
prev.selected === next.selected &&
|
||||
prev.cellsVersion === next.cellsVersion,
|
||||
);
|
||||
|
||||
export const SkeletonRows: React.MemoExoticComponent<
|
||||
|
|
|
|||
|
|
@ -94,11 +94,10 @@ describe('DataTableSearch', () => {
|
|||
render(<DataTableSearch value="" onChange={jest.fn()} />);
|
||||
|
||||
const input = screen.getByTestId('search-input');
|
||||
expect(input).toHaveAttribute('aria-describedby', 'search-description');
|
||||
|
||||
// Description should be present
|
||||
const description = screen.getByText('com_ui_search_table_description');
|
||||
expect(description).toHaveAttribute('id', 'search-description');
|
||||
expect(input).toHaveAttribute('aria-describedby', description.id);
|
||||
expect(description).toHaveClass('sr-only');
|
||||
});
|
||||
|
||||
|
|
@ -137,8 +136,8 @@ describe('DataTableSearch', () => {
|
|||
const input = screen.getByTestId('search-input');
|
||||
const label = screen.getByText('com_ui_search_table');
|
||||
|
||||
expect(input).toHaveAttribute('id', 'table-search');
|
||||
expect(label).toHaveAttribute('for', 'table-search');
|
||||
expect(input.id).not.toBe('');
|
||||
expect(label).toHaveAttribute('for', input.id);
|
||||
});
|
||||
|
||||
it('should handle empty string onChange', () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { startTransition } from 'react';
|
||||
import { memo, MemoExoticComponent } from 'react';
|
||||
import { memo, startTransition, useId, type MemoExoticComponent } from 'react';
|
||||
import { JSX } from 'react/jsx-runtime';
|
||||
import type { DataTableSearchProps } from './DataTable.types';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
|
@ -17,25 +16,27 @@ export const DataTableSearch: MemoExoticComponent<
|
|||
disabled = false,
|
||||
}: DataTableSearchProps): JSX.Element => {
|
||||
const localize = useLocalize();
|
||||
const searchId = useId();
|
||||
const descriptionId = `${searchId}-description`;
|
||||
|
||||
return (
|
||||
<div className="relative flex-1">
|
||||
<label htmlFor="table-search" className="sr-only">
|
||||
<label htmlFor={searchId} className="sr-only">
|
||||
{localize('com_ui_search_table')}
|
||||
</label>
|
||||
<Input
|
||||
id="table-search"
|
||||
id={searchId}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
startTransition(() => onChange(e.target.value));
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label={localize('com_ui_search_table')}
|
||||
aria-describedby="search-description"
|
||||
aria-describedby={descriptionId}
|
||||
placeholder={placeholder || localize('com_ui_search')}
|
||||
className={cn('h-10 rounded-b-none border-0 bg-surface-secondary md:h-12', className)}
|
||||
/>
|
||||
<span id="search-description" className="sr-only">
|
||||
<span id={descriptionId} className="sr-only">
|
||||
{localize('com_ui_search_table_description')}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,31 @@
|
|||
import { useState } from 'react';
|
||||
import { JSX } from 'react/jsx-runtime';
|
||||
import { CircleHelpIcon } from 'lucide-react';
|
||||
import { CircleHelpIcon, InfoIcon } from 'lucide-react';
|
||||
import { HoverCard, HoverCardTrigger, HoverCardPortal, HoverCardContent } from './HoverCard';
|
||||
import { ESide } from '~/common';
|
||||
|
||||
type InfoHoverCardProps = {
|
||||
side?: ESide;
|
||||
text: string;
|
||||
icon?: 'help' | 'info';
|
||||
};
|
||||
|
||||
const InfoHoverCard = ({ side, text }: InfoHoverCardProps): JSX.Element => {
|
||||
const InfoHoverCard = ({ side, text, icon = 'help' }: InfoHoverCardProps): JSX.Element => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const Icon = icon === 'info' ? InfoIcon : CircleHelpIcon;
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={50} open={isOpen} onOpenChange={setIsOpen}>
|
||||
<HoverCardTrigger
|
||||
tabIndex={0}
|
||||
className="inline-flex cursor-help items-center justify-center rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary focus-visible:ring-offset-2"
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onBlur={() => setIsOpen(false)}
|
||||
aria-label={text}
|
||||
>
|
||||
<CircleHelpIcon className="h-5 w-5 text-text-tertiary" aria-hidden="true" />
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex cursor-help items-center justify-center rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary focus-visible:ring-offset-2"
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onBlur={() => setIsOpen(false)}
|
||||
aria-label={text}
|
||||
>
|
||||
<Icon className="h-5 w-5 text-text-tertiary" aria-hidden="true" />
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent side={side} className="z-[999] w-80">
|
||||
|
|
|
|||
|
|
@ -57,3 +57,12 @@ export { default as ControlCombobox } from './ControlCombobox';
|
|||
export { default as OGDialogTemplate } from './OGDialogTemplate';
|
||||
export { default as InputWithDropdown } from './InputWithDropDown';
|
||||
export { default as AnimatedSearchInput } from './AnimatedSearchInput';
|
||||
export { default as VirtualizedDataTable } from './DataTable/DataTable';
|
||||
export type {
|
||||
TableColumn,
|
||||
TableColumnDef,
|
||||
DataTableConfig,
|
||||
ProcessedDataRow,
|
||||
DataTableSearchProps,
|
||||
DataTableProps as VirtualizedDataTableProps,
|
||||
} from './DataTable/DataTable.types';
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ function createTailwindColors() {
|
|||
'surface-destructive': cssVar('--surface-destructive'),
|
||||
'surface-destructive-hover': cssVar('--surface-destructive-hover'),
|
||||
'surface-chat': cssVar('--surface-chat'),
|
||||
'surface-qr': cssVar('--surface-qr'),
|
||||
'surface-inverted': cssVar('--surface-inverted'),
|
||||
'surface-inverted-hover': cssVar('--surface-inverted-hover'),
|
||||
'text-inverted': cssVar('--text-inverted'),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
import { buildLoginRedirectUrl } from '../src/api-endpoints';
|
||||
import { buildLoginRedirectUrl, getSharedLinks } from '../src/api-endpoints';
|
||||
|
||||
describe('buildLoginRedirectUrl', () => {
|
||||
afterEach(() => {
|
||||
|
|
@ -72,3 +72,19 @@ describe('buildLoginRedirectUrl', () => {
|
|||
expect(decodeURIComponent(result.split('redirect_to=')[1])).toBe('/c/loginhistory');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSharedLinks', () => {
|
||||
it('encodes search and cursor values exactly once', () => {
|
||||
const result = getSharedLinks(
|
||||
25,
|
||||
'createdAt',
|
||||
'desc',
|
||||
'100% ready & waiting',
|
||||
'2030-01-01T00:00:00.000Z',
|
||||
);
|
||||
|
||||
expect(result).toBe(
|
||||
'/api/share?pageSize=25&sortBy=createdAt&sortDirection=desc&search=100%25%20ready%20%26%20waiting&cursor=2030-01-01T00%3A00%3A00.000Z',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -80,10 +80,7 @@ export const getSharedLinks = (
|
|||
sortDirection: 'asc' | 'desc',
|
||||
search?: string,
|
||||
cursor?: string,
|
||||
) =>
|
||||
`${shareRoot}?pageSize=${pageSize}&sortBy=${sortBy}&sortDirection=${sortDirection}${
|
||||
search ? `&search=${search}` : ''
|
||||
}${cursor ? `&cursor=${cursor}` : ''}`;
|
||||
) => `${shareRoot}${buildQuery({ pageSize, sortBy, sortDirection, search, cursor })}`;
|
||||
export const createSharedLink = (conversationId: string) => `${shareRoot}/${conversationId}`;
|
||||
export const updateSharedLink = (shareId: string) => `${shareRoot}/${shareId}`;
|
||||
/** Share-scoped file routes: serve snapshotted files via shared-link permission. */
|
||||
|
|
|
|||
|
|
@ -844,8 +844,12 @@ export function forkConversation(payload: t.TForkConvoRequest): Promise<t.TForkC
|
|||
export function forkSharedConversation(
|
||||
shareId: string,
|
||||
targetMessageIndex?: number,
|
||||
shareRevision?: string,
|
||||
): Promise<t.TForkConvoResponse> {
|
||||
return request.post(endpoints.forkSharedMessages(shareId), { targetMessageIndex });
|
||||
return request.post(endpoints.forkSharedMessages(shareId), {
|
||||
targetMessageIndex,
|
||||
shareRevision,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteConversation(payload: t.TDeleteConversationRequest) {
|
||||
|
|
|
|||
|
|
@ -946,6 +946,8 @@ export const tConversationSchema = z.object({
|
|||
endpointType: eModelEndpointSchema.nullable().optional(),
|
||||
isArchived: z.boolean().optional(),
|
||||
pinned: z.boolean().optional(),
|
||||
/** Server-derived: an active shared link exists for this conversation. Not persisted. */
|
||||
isShared: z.boolean().optional(),
|
||||
title: z.string().nullable().or(z.literal('New Chat')).default('New Chat'),
|
||||
user: z.string().optional(),
|
||||
messages: z.array(z.string()).optional(),
|
||||
|
|
|
|||
|
|
@ -492,6 +492,10 @@ export type TForkSharedConvoRequest = {
|
|||
* fork to that branch. An index is used because shared ids are re-anonymized
|
||||
* per request and `createdAt` can collide, while the payload order is stable. */
|
||||
targetMessageIndex?: number;
|
||||
/** `updatedAt` of the shared payload being forked. The shareId survives an
|
||||
* owner update, so the server rejects a fork whose payload has since moved
|
||||
* instead of resolving the index against different messages. */
|
||||
shareRevision?: string;
|
||||
};
|
||||
|
||||
export type TSearchResults = {
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ export interface SharedLinksListParams {
|
|||
export type SharedLinkItem = {
|
||||
shareId: string;
|
||||
title: string;
|
||||
createdAt: Date;
|
||||
createdAt: string;
|
||||
conversationId: string;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1198,6 +1198,156 @@ describe('Conversation Operations', () => {
|
|||
return Conversation.findOne({ conversationId }).lean<IConversation>();
|
||||
};
|
||||
|
||||
it('should flag conversations that have an active shared link', async () => {
|
||||
const SharedLink = mongoose.models.SharedLink as mongoose.Model<{
|
||||
conversationId: string;
|
||||
user: string;
|
||||
shareId: string;
|
||||
expiredAt?: Date | null;
|
||||
}>;
|
||||
const baseTime = new Date('2026-03-01T00:00:00.000Z');
|
||||
const shared = await createConvoWithTimestamps(1, baseTime, baseTime);
|
||||
const unshared = await createConvoWithTimestamps(2, baseTime, baseTime);
|
||||
const expiredShare = await createConvoWithTimestamps(3, baseTime, baseTime);
|
||||
|
||||
await SharedLink.create([
|
||||
{
|
||||
conversationId: shared!.conversationId,
|
||||
user: 'user123',
|
||||
shareId: `share-${uuidv4()}`,
|
||||
},
|
||||
{
|
||||
conversationId: expiredShare!.conversationId,
|
||||
user: 'user123',
|
||||
shareId: `share-${uuidv4()}`,
|
||||
expiredAt: new Date('2020-01-01T00:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await methods.getConvosByCursor('user123', { limit: 25 });
|
||||
const byId = new Map(result.conversations.map((convo) => [convo.conversationId, convo]));
|
||||
|
||||
expect(byId.get(shared!.conversationId)?.isShared).toBe(true);
|
||||
expect(byId.get(unshared!.conversationId)?.isShared).toBe(false);
|
||||
expect(byId.get(expiredShare!.conversationId)?.isShared).toBe(false);
|
||||
|
||||
await SharedLink.deleteMany({ user: 'user123' });
|
||||
await Conversation.deleteMany({ user: 'user123' });
|
||||
});
|
||||
|
||||
it('should skip the shared lookup when shared links are disabled', async () => {
|
||||
const SharedLink = mongoose.models.SharedLink as mongoose.Model<{
|
||||
conversationId: string;
|
||||
user: string;
|
||||
shareId: string;
|
||||
}>;
|
||||
const baseTime = new Date('2026-03-02T00:00:00.000Z');
|
||||
const shared = await createConvoWithTimestamps(1, baseTime, baseTime);
|
||||
await SharedLink.create({
|
||||
conversationId: shared!.conversationId,
|
||||
user: 'user123',
|
||||
shareId: `share-${uuidv4()}`,
|
||||
});
|
||||
|
||||
process.env.ALLOW_SHARED_LINKS = 'false';
|
||||
try {
|
||||
const result = await methods.getConvosByCursor('user123', { limit: 25 });
|
||||
expect(result.conversations[0].isShared).toBeUndefined();
|
||||
} finally {
|
||||
delete process.env.ALLOW_SHARED_LINKS;
|
||||
}
|
||||
|
||||
await SharedLink.deleteMany({ user: 'user123' });
|
||||
await Conversation.deleteMany({ user: 'user123' });
|
||||
});
|
||||
|
||||
it('should page through titles that share a timestamp', async () => {
|
||||
// Imports land with identical titles and timestamps, so (title, updatedAt)
|
||||
// alone cannot mark where the previous page stopped.
|
||||
const sameTime = new Date('2026-04-01T00:00:00.000Z');
|
||||
for (let index = 0; index < 6; index++) {
|
||||
await Conversation.collection.insertOne({
|
||||
conversationId: uuidv4(),
|
||||
user: 'user123',
|
||||
title: 'Imported chat',
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
expiredAt: null,
|
||||
isArchived: false,
|
||||
createdAt: sameTime,
|
||||
updatedAt: sameTime,
|
||||
});
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
let cursor: string | null = null;
|
||||
for (let page = 0; page < 6; page++) {
|
||||
const result = await methods.getConvosByCursor('user123', {
|
||||
limit: 2,
|
||||
sortBy: 'title',
|
||||
sortDirection: 'asc',
|
||||
cursor,
|
||||
});
|
||||
result.conversations.forEach((convo) => seen.add(convo.conversationId));
|
||||
cursor = result.nextCursor;
|
||||
if (!cursor) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(seen.size).toBe(6);
|
||||
|
||||
await Conversation.deleteMany({ user: 'user123' });
|
||||
});
|
||||
|
||||
it('should page past conversations that have no title', async () => {
|
||||
const sameTime = new Date('2026-04-15T00:00:00.000Z');
|
||||
for (let index = 0; index < 3; index++) {
|
||||
await Conversation.collection.insertOne({
|
||||
conversationId: uuidv4(),
|
||||
user: 'user123',
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
expiredAt: null,
|
||||
isArchived: false,
|
||||
createdAt: sameTime,
|
||||
updatedAt: sameTime,
|
||||
});
|
||||
await Conversation.collection.insertOne({
|
||||
conversationId: uuidv4(),
|
||||
user: 'user123',
|
||||
title: `Named ${index}`,
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
expiredAt: null,
|
||||
isArchived: false,
|
||||
createdAt: sameTime,
|
||||
updatedAt: sameTime,
|
||||
});
|
||||
}
|
||||
|
||||
// Missing titles sort before every string, and comparison operators never cross
|
||||
// that type boundary, so the group needs cursor clauses of its own.
|
||||
for (const sortDirection of ['asc', 'desc']) {
|
||||
const seen = new Set<string>();
|
||||
let cursor: string | null = null;
|
||||
for (let page = 0; page < 6; page++) {
|
||||
const result = await methods.getConvosByCursor('user123', {
|
||||
limit: 2,
|
||||
sortBy: 'title',
|
||||
sortDirection,
|
||||
cursor,
|
||||
});
|
||||
result.conversations.forEach((convo) => seen.add(convo.conversationId));
|
||||
cursor = result.nextCursor;
|
||||
if (!cursor) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(seen.size).toBe(6);
|
||||
}
|
||||
|
||||
await Conversation.deleteMany({ user: 'user123' });
|
||||
});
|
||||
|
||||
it('should not skip conversations at page boundaries', async () => {
|
||||
// Create 30 conversations to ensure pagination (limit is 25)
|
||||
const baseTime = new Date('2026-01-01T00:00:00.000Z');
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
import { RetentionMode } from 'librechat-data-provider';
|
||||
import type { FilterQuery, Model, SortOrder } from 'mongoose';
|
||||
import type { DeleteResult } from 'mongoose';
|
||||
import type { AppConfig, IChatProjectDocument, IConversation } from '~/types';
|
||||
import type { AppConfig, IChatProjectDocument, IConversation, ISharedLink } from '~/types';
|
||||
import type { MessageMethods } from './message';
|
||||
import {
|
||||
activeExpirationFilter,
|
||||
buildRetentionVisibilityFilter,
|
||||
createFallbackRetentionDate,
|
||||
} from '~/utils/retention';
|
||||
import {
|
||||
refreshChatProjectStatsForUser,
|
||||
updateChatProjectLastConversationForUser,
|
||||
} from './chatProject';
|
||||
import { buildRetentionVisibilityFilter, createFallbackRetentionDate } from '~/utils/retention';
|
||||
import { createTempChatExpirationDate } from '~/utils/tempChatRetention';
|
||||
import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite';
|
||||
import { isValidObjectIdString } from '~/utils/objectId';
|
||||
|
|
@ -519,6 +523,42 @@ export function createConversationMethods(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags which conversations on a page currently have an active shared link, in one
|
||||
* batched lookup instead of a query per row. The flag lives in another collection, so
|
||||
* it is derived per request rather than projected; a failure here degrades the badge
|
||||
* but must never fail the conversation list itself.
|
||||
*/
|
||||
async function attachSharedFlags(user: string, conversations: IConversation[]): Promise<void> {
|
||||
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink> | undefined;
|
||||
if (!SharedLink || conversations.length === 0) {
|
||||
return;
|
||||
}
|
||||
/* A deployment with shared links off serves no links and renders no badge, so the
|
||||
extra round trip on the sidebar's first page would buy nothing. */
|
||||
const allowSharedLinks = process.env.ALLOW_SHARED_LINKS;
|
||||
if (allowSharedLinks !== undefined && allowSharedLinks.toLowerCase().trim() !== 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const shares = await SharedLink.find({
|
||||
user,
|
||||
conversationId: { $in: conversations.map((convo) => convo.conversationId) },
|
||||
...activeExpirationFilter<ISharedLink>(),
|
||||
})
|
||||
.select('conversationId')
|
||||
.lean();
|
||||
|
||||
const shared = new Set(shares.map((share) => share.conversationId));
|
||||
for (const convo of conversations) {
|
||||
convo.isShared = shared.has(convo.conversationId);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[attachSharedFlags] Error resolving shared conversations', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves conversations using cursor-based pagination.
|
||||
*/
|
||||
|
|
@ -606,20 +646,54 @@ export function createConversationMethods(
|
|||
if (cursor) {
|
||||
try {
|
||||
const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString());
|
||||
const { primary, secondary } = decoded;
|
||||
const primaryValue = finalSortBy === 'title' ? primary : new Date(primary);
|
||||
const { primary, secondary, id } = decoded;
|
||||
const secondaryValue = new Date(secondary);
|
||||
const op = finalSortDirection === 'asc' ? '$gt' : '$lt';
|
||||
const descending = finalSortDirection !== 'asc';
|
||||
const op = descending ? '$lt' : '$gt';
|
||||
const sortsByUpdatedAt = finalSortBy === 'updatedAt';
|
||||
const boundaryId =
|
||||
typeof id === 'string' && isValidObjectIdString(id)
|
||||
? { [op]: new mongoose.Types.ObjectId(id) }
|
||||
: null;
|
||||
|
||||
cursorFilter = {
|
||||
$or: [
|
||||
{ [finalSortBy]: { [op]: primaryValue } },
|
||||
{
|
||||
/* One clause per sort level, so the page boundary is exact. Titles and
|
||||
timestamps both repeat; `_id` is the only field guaranteed to break the
|
||||
tie, and without that last clause every row sharing the boundary's
|
||||
(sort field, updatedAt) pair is skipped instead of returned. */
|
||||
const clauses: FilterQuery<IConversation>[] = [];
|
||||
|
||||
/* A title can be absent, and BSON orders a missing field before every
|
||||
string while `$lt`/`$gt` never cross that type boundary. Titleless rows
|
||||
therefore need clauses of their own: their own tail when the boundary is
|
||||
one of them, and the whole group when a descending page runs past the
|
||||
last title. */
|
||||
if (primary == null) {
|
||||
clauses.push({ [finalSortBy]: null, updatedAt: { [op]: secondaryValue } });
|
||||
if (boundaryId) {
|
||||
clauses.push({ [finalSortBy]: null, updatedAt: secondaryValue, _id: boundaryId });
|
||||
}
|
||||
if (!descending) {
|
||||
clauses.push({ [finalSortBy]: { $ne: null } });
|
||||
}
|
||||
} else {
|
||||
const primaryValue = finalSortBy === 'title' ? primary : new Date(primary);
|
||||
clauses.push({ [finalSortBy]: { [op]: primaryValue } });
|
||||
if (!sortsByUpdatedAt) {
|
||||
clauses.push({ [finalSortBy]: primaryValue, updatedAt: { [op]: secondaryValue } });
|
||||
}
|
||||
if (boundaryId) {
|
||||
clauses.push({
|
||||
[finalSortBy]: primaryValue,
|
||||
updatedAt: { [op]: secondaryValue },
|
||||
},
|
||||
],
|
||||
} as FilterQuery<IConversation>;
|
||||
...(sortsByUpdatedAt ? {} : { updatedAt: secondaryValue }),
|
||||
_id: boundaryId,
|
||||
});
|
||||
}
|
||||
if (descending && finalSortBy === 'title') {
|
||||
clauses.push({ [finalSortBy]: null });
|
||||
}
|
||||
}
|
||||
|
||||
cursorFilter = { $or: clauses } as FilterQuery<IConversation>;
|
||||
} catch {
|
||||
logger.warn('[getConvosByCursor] Invalid cursor format, starting from beginning');
|
||||
}
|
||||
|
|
@ -638,6 +712,7 @@ export function createConversationMethods(
|
|||
if (finalSortBy !== 'updatedAt') {
|
||||
sortObj.updatedAt = sortOrder;
|
||||
}
|
||||
sortObj._id = sortOrder;
|
||||
|
||||
const convos = await Conversation.find(query)
|
||||
.select(
|
||||
|
|
@ -658,12 +733,20 @@ export function createConversationMethods(
|
|||
primaryValue = lastReturned.createdAt;
|
||||
}
|
||||
const primaryStr =
|
||||
finalSortBy === 'title' ? primaryValue : new Date(primaryValue ?? 0).toISOString();
|
||||
finalSortBy === 'title'
|
||||
? (primaryValue ?? null)
|
||||
: new Date(primaryValue ?? 0).toISOString();
|
||||
const secondaryStr = new Date(lastReturned.updatedAt ?? 0).toISOString();
|
||||
const composite = { primary: primaryStr, secondary: secondaryStr };
|
||||
const composite = {
|
||||
primary: primaryStr,
|
||||
secondary: secondaryStr,
|
||||
id: String(lastReturned._id),
|
||||
};
|
||||
nextCursor = Buffer.from(JSON.stringify(composite)).toString('base64');
|
||||
}
|
||||
|
||||
await attachSharedFlags(user, convos);
|
||||
|
||||
return { conversations: convos, nextCursor };
|
||||
} catch (error) {
|
||||
logger.error('[getConvosByCursor] Error getting conversations', error);
|
||||
|
|
@ -711,6 +794,8 @@ export function createConversationMethods(
|
|||
nextCursor = (limited[limited.length - 1].updatedAt as Date).toISOString();
|
||||
}
|
||||
|
||||
await attachSharedFlags(user, limited);
|
||||
|
||||
const convoMap: Record<string, unknown> = {};
|
||||
limited.forEach((convo) => {
|
||||
convoMap[convo.conversationId] = convo;
|
||||
|
|
|
|||
|
|
@ -192,6 +192,39 @@ describe('Share Methods', () => {
|
|||
);
|
||||
});
|
||||
|
||||
test('should leave a single active share when creates race the existence check', async () => {
|
||||
const userId = new mongoose.Types.ObjectId().toString();
|
||||
const conversationId = `conv_${nanoid()}`;
|
||||
|
||||
await Conversation.create({ conversationId, user: userId, title: 'Racing Conversation' });
|
||||
await Message.create({
|
||||
messageId: `msg_${nanoid()}`,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Test message',
|
||||
isCreatedByUser: true,
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
shareMethods.createSharedLink(userId, conversationId),
|
||||
shareMethods.createSharedLink(userId, conversationId),
|
||||
shareMethods.createSharedLink(userId, conversationId),
|
||||
]);
|
||||
|
||||
const fulfilled = results.filter((result) => result.status === 'fulfilled');
|
||||
expect(fulfilled).toHaveLength(1);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
expect(result.reason).toMatchObject({ code: 'SHARE_EXISTS' });
|
||||
}
|
||||
}
|
||||
|
||||
const surviving = await SharedLink.find({ conversationId, user: userId }).lean();
|
||||
expect(surviving).toHaveLength(1);
|
||||
const winner = fulfilled[0] as PromiseFulfilledResult<t.CreateShareResult>;
|
||||
expect(surviving[0].shareId).toBe(winner.value.shareId);
|
||||
});
|
||||
|
||||
test('should ignore expired shares when checking for duplicates', async () => {
|
||||
const userId = new mongoose.Types.ObjectId().toString();
|
||||
const conversationId = `conv_${nanoid()}`;
|
||||
|
|
@ -327,6 +360,28 @@ describe('Share Methods', () => {
|
|||
const shares = await SharedLink.find({ conversationId });
|
||||
expect(shares).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should reject a target message that is not in the owned conversation', async () => {
|
||||
const userId = new mongoose.Types.ObjectId().toString();
|
||||
const conversationId = `conv_${nanoid()}`;
|
||||
|
||||
await Conversation.create({ conversationId, title: 'Targeted Share', user: userId });
|
||||
await Message.create({
|
||||
messageId: `msg_${nanoid()}`,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Existing message',
|
||||
isCreatedByUser: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
shareMethods.createSharedLink(userId, conversationId, 'missing-message'),
|
||||
).rejects.toMatchObject({
|
||||
code: 'TARGET_MESSAGE_NOT_FOUND',
|
||||
message: 'Target message not found',
|
||||
});
|
||||
expect(await SharedLink.countDocuments({ conversationId })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSharedMessages', () => {
|
||||
|
|
@ -400,6 +455,34 @@ describe('Share Methods', () => {
|
|||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('fails closed when a stored target message no longer exists', async () => {
|
||||
const userId = new mongoose.Types.ObjectId().toString();
|
||||
const conversationId = `conv_${nanoid()}`;
|
||||
const shareId = `share_${nanoid()}`;
|
||||
const messages = await Message.create([
|
||||
{
|
||||
messageId: `msg_${nanoid()}`,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Must not be widened into the share',
|
||||
isCreatedByUser: true,
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
},
|
||||
]);
|
||||
|
||||
await SharedLink.create({
|
||||
shareId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
targetMessageId: 'missing-message',
|
||||
messages: messages.map((message) => message._id),
|
||||
});
|
||||
|
||||
const result = await shareMethods.getSharedMessages(shareId);
|
||||
|
||||
expect(result?.messages).toEqual([]);
|
||||
});
|
||||
|
||||
test('should handle messages with attachments', async () => {
|
||||
const userId = new mongoose.Types.ObjectId().toString();
|
||||
const conversationId = `conv_${nanoid()}`;
|
||||
|
|
@ -712,6 +795,116 @@ describe('Share Methods', () => {
|
|||
expect(result.links[9].title).toBe('Share 9');
|
||||
});
|
||||
|
||||
test('should page through titles that repeat', async () => {
|
||||
const userId = new mongoose.Types.ObjectId().toString();
|
||||
const createdAt = new Date('2026-05-01T00:00:00.000Z');
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: 6 }, (_, i) =>
|
||||
SharedLink.create({
|
||||
shareId: `dupe_${i}`,
|
||||
conversationId: `conv_dupe_${i}`,
|
||||
user: userId,
|
||||
title: 'Untitled',
|
||||
createdAt,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const seen = new Set<string>();
|
||||
let cursor: string | undefined;
|
||||
for (let page = 0; page < 6; page++) {
|
||||
const result = await shareMethods.getSharedLinks(userId, cursor, 2, 'title', 'asc');
|
||||
result.links.forEach((link) => seen.add(link.shareId));
|
||||
cursor = result.nextCursor as string | undefined;
|
||||
if (!result.hasNextPage) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(seen.size).toBe(6);
|
||||
});
|
||||
|
||||
test('should page past links that have no title', async () => {
|
||||
const userId = new mongoose.Types.ObjectId().toString();
|
||||
|
||||
await Promise.all([
|
||||
...Array.from({ length: 3 }, (_, i) =>
|
||||
SharedLink.create({
|
||||
shareId: `untitled_${i}`,
|
||||
conversationId: `conv_untitled_${i}`,
|
||||
user: userId,
|
||||
}),
|
||||
),
|
||||
...Array.from({ length: 3 }, (_, i) =>
|
||||
SharedLink.create({
|
||||
shareId: `titled_${i}`,
|
||||
conversationId: `conv_titled_${i}`,
|
||||
user: userId,
|
||||
title: `Title ${i}`,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
// BSON orders missing titles before every string, so the boundary between the
|
||||
// two groups is the case a value-only cursor cannot express.
|
||||
for (const direction of ['asc', 'desc'] as const) {
|
||||
const seen: string[] = [];
|
||||
let cursor: string | undefined;
|
||||
for (let page = 0; page < 6; page++) {
|
||||
const result = await shareMethods.getSharedLinks(userId, cursor, 2, 'title', direction);
|
||||
result.links.forEach((link) => seen.push(link.shareId));
|
||||
cursor = result.nextCursor as string | undefined;
|
||||
if (!result.hasNextPage) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(seen).toHaveLength(6);
|
||||
expect(new Set(seen).size).toBe(6);
|
||||
}
|
||||
});
|
||||
|
||||
test('should serve nothing when the target is cut off from the roots', async () => {
|
||||
const userId = new mongoose.Types.ObjectId().toString();
|
||||
const conversationId = `conv_${nanoid()}`;
|
||||
const shareId = `share_${nanoid()}`;
|
||||
const rootId = `msg_${nanoid()}`;
|
||||
const orphanId = `msg_${nanoid()}`;
|
||||
|
||||
await Conversation.create({ conversationId, title: 'Orphaned', user: userId });
|
||||
const root = await Message.create({
|
||||
messageId: rootId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Private root turn',
|
||||
isCreatedByUser: true,
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
});
|
||||
// An import or a partial delete can leave a branch whose parent is gone.
|
||||
const orphan = await Message.create({
|
||||
messageId: orphanId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Orphan branch',
|
||||
isCreatedByUser: false,
|
||||
parentMessageId: `msg_${nanoid()}`,
|
||||
});
|
||||
|
||||
await SharedLink.create({
|
||||
shareId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
messages: [root._id, orphan._id],
|
||||
targetMessageId: orphanId,
|
||||
});
|
||||
|
||||
const result = await shareMethods.getSharedMessages(shareId);
|
||||
|
||||
// Failing open here would publish the whole conversation instead of the branch.
|
||||
expect(result?.messages).toEqual([]);
|
||||
});
|
||||
|
||||
test('should exclude expired shares', async () => {
|
||||
const userId = new mongoose.Types.ObjectId().toString();
|
||||
|
||||
|
|
@ -912,7 +1105,7 @@ describe('Share Methods', () => {
|
|||
});
|
||||
|
||||
describe('updateSharedLink', () => {
|
||||
test('should update shared link with new messages', async () => {
|
||||
test('should update the existing shared link with new messages', async () => {
|
||||
const userId = new mongoose.Types.ObjectId().toString();
|
||||
const conversationId = `conv_${nanoid()}`;
|
||||
const oldShareId = `share_${nanoid()}`;
|
||||
|
|
@ -948,7 +1141,7 @@ describe('Share Methods', () => {
|
|||
const result = await shareMethods.updateSharedLink(userId, oldShareId);
|
||||
|
||||
expect(result._id).toBeDefined();
|
||||
expect(result.shareId).not.toBe(oldShareId); // Should generate new shareId
|
||||
expect(result.shareId).toBe(oldShareId);
|
||||
expect(result.conversationId).toBe(conversationId);
|
||||
|
||||
// Verify updated share
|
||||
|
|
@ -1024,6 +1217,28 @@ describe('Share Methods', () => {
|
|||
);
|
||||
});
|
||||
|
||||
test('should reject a refresh target that is not in the current conversation', async () => {
|
||||
const userId = new mongoose.Types.ObjectId().toString();
|
||||
const conversationId = `conv_${nanoid()}`;
|
||||
const shareId = `share_${nanoid()}`;
|
||||
await SharedLink.create({ shareId, conversationId, user: userId, messages: [] });
|
||||
await Message.create({
|
||||
messageId: `msg_${nanoid()}`,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Current message',
|
||||
isCreatedByUser: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
shareMethods.updateSharedLink(userId, shareId, 'missing-message'),
|
||||
).rejects.toMatchObject({
|
||||
code: 'TARGET_MESSAGE_NOT_FOUND',
|
||||
message: 'Target message not found',
|
||||
});
|
||||
expect(await SharedLink.findOne({ shareId })).not.toBeNull();
|
||||
});
|
||||
|
||||
test('should only update with messages from the same user', async () => {
|
||||
const userId = new mongoose.Types.ObjectId().toString();
|
||||
const otherUserId = new mongoose.Types.ObjectId().toString();
|
||||
|
|
@ -1134,7 +1349,7 @@ describe('Share Methods', () => {
|
|||
);
|
||||
const sharedMessages = await shareMethods.getSharedMessages(result.shareId);
|
||||
|
||||
expect(result.shareId).not.toBe(shareId);
|
||||
expect(result.shareId).toBe(shareId);
|
||||
expect(result.targetMessageId).toBe(rerunAnswerId);
|
||||
expect(updatedShare?.targetMessageId).toBe(rerunAnswerId);
|
||||
expect(updatedShare?.messages).toHaveLength(4);
|
||||
|
|
@ -1159,6 +1374,14 @@ describe('Share Methods', () => {
|
|||
messages: [],
|
||||
targetMessageId,
|
||||
});
|
||||
await Message.create({
|
||||
messageId: targetMessageId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Existing target',
|
||||
isCreatedByUser: true,
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
});
|
||||
|
||||
const result = await shareMethods.updateSharedLink(userId, shareId);
|
||||
const updatedShare = await SharedLink.findOne({ shareId: result.shareId });
|
||||
|
|
@ -1167,6 +1390,343 @@ describe('Share Methods', () => {
|
|||
expect(updatedShare?.targetMessageId).toBe(targetMessageId);
|
||||
});
|
||||
|
||||
test('should publish turns added since the last share when no target override is given', async () => {
|
||||
const userId = new mongoose.Types.ObjectId().toString();
|
||||
const conversationId = `conv_${nanoid()}`;
|
||||
const shareId = `share_${nanoid()}`;
|
||||
const rootMessageId = `msg_${nanoid()}`;
|
||||
const sharedAnswerId = `msg_${nanoid()}`;
|
||||
const laterPromptId = `msg_${nanoid()}`;
|
||||
const laterAnswerId = `msg_${nanoid()}`;
|
||||
|
||||
await Conversation.create({ conversationId, title: 'Ongoing', user: userId });
|
||||
const initialMessages = await Message.create([
|
||||
{
|
||||
messageId: rootMessageId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'First question',
|
||||
isCreatedByUser: true,
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
},
|
||||
{
|
||||
messageId: sharedAnswerId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'First answer',
|
||||
isCreatedByUser: false,
|
||||
parentMessageId: rootMessageId,
|
||||
},
|
||||
]);
|
||||
|
||||
await SharedLink.create({
|
||||
shareId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
messages: initialMessages.map((message) => message._id),
|
||||
targetMessageId: sharedAnswerId,
|
||||
});
|
||||
|
||||
await Message.create([
|
||||
{
|
||||
messageId: laterPromptId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Follow-up question',
|
||||
isCreatedByUser: true,
|
||||
parentMessageId: sharedAnswerId,
|
||||
},
|
||||
{
|
||||
messageId: laterAnswerId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Follow-up answer',
|
||||
isCreatedByUser: false,
|
||||
parentMessageId: laterPromptId,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await shareMethods.updateSharedLink(userId, shareId);
|
||||
const sharedMessages = await shareMethods.getSharedMessages(result.shareId);
|
||||
|
||||
expect(result.shareId).toBe(shareId);
|
||||
expect(result.targetMessageId).toBe(laterAnswerId);
|
||||
expect(sharedMessages?.messages.map((message) => message.text)).toEqual([
|
||||
'First question',
|
||||
'First answer',
|
||||
'Follow-up question',
|
||||
'Follow-up answer',
|
||||
]);
|
||||
});
|
||||
|
||||
test('should advance to the newest sibling and stay bounded by that level', async () => {
|
||||
const userId = new mongoose.Types.ObjectId().toString();
|
||||
const conversationId = `conv_${nanoid()}`;
|
||||
const shareId = `share_${nanoid()}`;
|
||||
const rootMessageId = `msg_${nanoid()}`;
|
||||
const olderBranchId = `msg_${nanoid()}`;
|
||||
const newerBranchId = `msg_${nanoid()}`;
|
||||
const deeperMessageId = `msg_${nanoid()}`;
|
||||
|
||||
await Conversation.create({ conversationId, title: 'Branched', user: userId });
|
||||
const rootMessage = await Message.create({
|
||||
messageId: rootMessageId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Question',
|
||||
isCreatedByUser: true,
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
});
|
||||
|
||||
await SharedLink.create({
|
||||
shareId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
messages: [rootMessage._id],
|
||||
targetMessageId: rootMessageId,
|
||||
});
|
||||
|
||||
await Message.create({
|
||||
messageId: olderBranchId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Discarded regeneration',
|
||||
isCreatedByUser: false,
|
||||
parentMessageId: rootMessageId,
|
||||
createdAt: new Date(Date.now() - 60_000),
|
||||
});
|
||||
await Message.create({
|
||||
messageId: newerBranchId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Kept regeneration',
|
||||
isCreatedByUser: false,
|
||||
parentMessageId: rootMessageId,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
await Message.create({
|
||||
messageId: deeperMessageId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Reply below the advanced target',
|
||||
isCreatedByUser: true,
|
||||
parentMessageId: newerBranchId,
|
||||
});
|
||||
|
||||
const result = await shareMethods.updateSharedLink(userId, shareId);
|
||||
const sharedMessages = await shareMethods.getSharedMessages(result.shareId);
|
||||
const texts = sharedMessages?.messages.map((message) => message.text) ?? [];
|
||||
|
||||
// The tail is the deepest descendant, so the reply below the regeneration is included.
|
||||
expect(result.targetMessageId).toBe(deeperMessageId);
|
||||
expect(texts).toContain('Kept regeneration');
|
||||
expect(texts).toContain('Reply below the advanced target');
|
||||
// `getMessagesUpToTarget` bounds by level, so the same-level sibling comes along.
|
||||
expect(texts).toContain('Discarded regeneration');
|
||||
});
|
||||
|
||||
test('should follow the regeneration that replaced the stored target', async () => {
|
||||
const userId = new mongoose.Types.ObjectId().toString();
|
||||
const conversationId = `conv_${nanoid()}`;
|
||||
const shareId = `share_${nanoid()}`;
|
||||
const questionId = `msg_${nanoid()}`;
|
||||
const sharedAnswerId = `msg_${nanoid()}`;
|
||||
const regeneratedAnswerId = `msg_${nanoid()}`;
|
||||
const followUpId = `msg_${nanoid()}`;
|
||||
|
||||
await Conversation.create({ conversationId, title: 'Regenerated', user: userId });
|
||||
const question = await Message.create({
|
||||
messageId: questionId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Question',
|
||||
isCreatedByUser: true,
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
});
|
||||
const sharedAnswer = await Message.create({
|
||||
messageId: sharedAnswerId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Shared answer',
|
||||
isCreatedByUser: false,
|
||||
parentMessageId: questionId,
|
||||
createdAt: new Date(Date.now() - 60_000),
|
||||
});
|
||||
|
||||
await SharedLink.create({
|
||||
shareId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
messages: [question._id, sharedAnswer._id],
|
||||
targetMessageId: sharedAnswerId,
|
||||
});
|
||||
|
||||
// A regeneration lands as a sibling of the shared answer, not as its child.
|
||||
await Message.create({
|
||||
messageId: regeneratedAnswerId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Regenerated answer',
|
||||
isCreatedByUser: false,
|
||||
parentMessageId: questionId,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
await Message.create({
|
||||
messageId: followUpId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Turn added after the regeneration',
|
||||
isCreatedByUser: true,
|
||||
parentMessageId: regeneratedAnswerId,
|
||||
});
|
||||
|
||||
const result = await shareMethods.updateSharedLink(userId, shareId);
|
||||
const sharedMessages = await shareMethods.getSharedMessages(result.shareId);
|
||||
const texts = sharedMessages?.messages.map((message) => message.text) ?? [];
|
||||
|
||||
expect(result.targetMessageId).toBe(followUpId);
|
||||
expect(texts).toContain('Regenerated answer');
|
||||
expect(texts).toContain('Turn added after the regeneration');
|
||||
});
|
||||
|
||||
test('should follow a regeneration further up the shared branch', async () => {
|
||||
const userId = new mongoose.Types.ObjectId().toString();
|
||||
const conversationId = `conv_${nanoid()}`;
|
||||
const shareId = `share_${nanoid()}`;
|
||||
const questionId = `msg_${nanoid()}`;
|
||||
const answerId = `msg_${nanoid()}`;
|
||||
const sharedTailId = `msg_${nanoid()}`;
|
||||
const regeneratedAnswerId = `msg_${nanoid()}`;
|
||||
const newTailId = `msg_${nanoid()}`;
|
||||
|
||||
await Conversation.create({ conversationId, title: 'Deep regeneration', user: userId });
|
||||
const question = await Message.create({
|
||||
messageId: questionId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Question',
|
||||
isCreatedByUser: true,
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
});
|
||||
const answer = await Message.create({
|
||||
messageId: answerId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Answer',
|
||||
isCreatedByUser: false,
|
||||
parentMessageId: questionId,
|
||||
createdAt: new Date(Date.now() - 120_000),
|
||||
});
|
||||
const sharedTail = await Message.create({
|
||||
messageId: sharedTailId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Shared tail',
|
||||
isCreatedByUser: true,
|
||||
parentMessageId: answerId,
|
||||
createdAt: new Date(Date.now() - 90_000),
|
||||
});
|
||||
|
||||
await SharedLink.create({
|
||||
shareId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
messages: [question._id, answer._id, sharedTail._id],
|
||||
targetMessageId: sharedTailId,
|
||||
});
|
||||
|
||||
// The regeneration replaces the answer above the shared tail, so nothing below
|
||||
// the stored target moves: the conversation continues under the replacement.
|
||||
await Message.create({
|
||||
messageId: regeneratedAnswerId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Regenerated answer',
|
||||
isCreatedByUser: false,
|
||||
parentMessageId: questionId,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
await Message.create({
|
||||
messageId: newTailId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Turn on the replacement branch',
|
||||
isCreatedByUser: true,
|
||||
parentMessageId: regeneratedAnswerId,
|
||||
});
|
||||
|
||||
const result = await shareMethods.updateSharedLink(userId, shareId);
|
||||
const sharedMessages = await shareMethods.getSharedMessages(result.shareId);
|
||||
const texts = sharedMessages?.messages.map((message) => message.text) ?? [];
|
||||
|
||||
expect(result.targetMessageId).toBe(newTailId);
|
||||
expect(texts).toContain('Turn on the replacement branch');
|
||||
});
|
||||
|
||||
test('should not resume down an older sibling branch', async () => {
|
||||
const userId = new mongoose.Types.ObjectId().toString();
|
||||
const conversationId = `conv_${nanoid()}`;
|
||||
const shareId = `share_${nanoid()}`;
|
||||
const questionId = `msg_${nanoid()}`;
|
||||
const abandonedId = `msg_${nanoid()}`;
|
||||
const abandonedFollowUpId = `msg_${nanoid()}`;
|
||||
const keptId = `msg_${nanoid()}`;
|
||||
|
||||
await Conversation.create({ conversationId, title: 'Regenerated tail', user: userId });
|
||||
const question = await Message.create({
|
||||
messageId: questionId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Question',
|
||||
isCreatedByUser: true,
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
});
|
||||
await Message.create({
|
||||
messageId: abandonedId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Abandoned answer',
|
||||
isCreatedByUser: false,
|
||||
parentMessageId: questionId,
|
||||
createdAt: new Date(Date.now() - 120_000),
|
||||
});
|
||||
await Message.create({
|
||||
messageId: abandonedFollowUpId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Turn on the abandoned branch',
|
||||
isCreatedByUser: true,
|
||||
parentMessageId: abandonedId,
|
||||
createdAt: new Date(Date.now() - 90_000),
|
||||
});
|
||||
// The shared target is the regeneration itself: newest, and with nothing under it.
|
||||
const kept = await Message.create({
|
||||
messageId: keptId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Kept answer',
|
||||
isCreatedByUser: false,
|
||||
parentMessageId: questionId,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
await SharedLink.create({
|
||||
shareId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
messages: [question._id, kept._id],
|
||||
targetMessageId: keptId,
|
||||
});
|
||||
|
||||
const result = await shareMethods.updateSharedLink(userId, shareId);
|
||||
const sharedMessages = await shareMethods.getSharedMessages(result.shareId);
|
||||
const texts = sharedMessages?.messages.map((message) => message.text) ?? [];
|
||||
|
||||
expect(result.targetMessageId).toBe(keptId);
|
||||
expect(texts).not.toContain('Turn on the abandoned branch');
|
||||
});
|
||||
|
||||
test('should not allow user to update shared link they do not own', async () => {
|
||||
const ownerUserId = new mongoose.Types.ObjectId().toString();
|
||||
const otherUserId = new mongoose.Types.ObjectId().toString();
|
||||
|
|
@ -1652,6 +2212,40 @@ describe('Share Methods', () => {
|
|||
expect(result?.messages[1].conversationId).toBe(result?.conversationId);
|
||||
});
|
||||
|
||||
test('does not correlate the same private conversation across separate links', async () => {
|
||||
const userId = new mongoose.Types.ObjectId().toString();
|
||||
const conversationId = `conv_${nanoid()}`;
|
||||
const firstShareId = `share_${nanoid()}`;
|
||||
const secondShareId = `share_${nanoid()}`;
|
||||
const message = await Message.create({
|
||||
messageId: `msg_${nanoid()}`,
|
||||
conversationId,
|
||||
user: userId,
|
||||
text: 'Same private conversation',
|
||||
isCreatedByUser: true,
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
});
|
||||
await SharedLink.create([
|
||||
{
|
||||
shareId: firstShareId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
messages: [message._id],
|
||||
},
|
||||
{
|
||||
shareId: secondShareId,
|
||||
conversationId,
|
||||
user: userId,
|
||||
messages: [message._id],
|
||||
},
|
||||
]);
|
||||
|
||||
const first = await shareMethods.getSharedMessages(firstShareId);
|
||||
const second = await shareMethods.getSharedMessages(secondShareId);
|
||||
|
||||
expect(first?.conversationId).not.toBe(second?.conversationId);
|
||||
});
|
||||
|
||||
test('should handle NO_PARENT constant correctly', async () => {
|
||||
const { Constants } = await import('librechat-data-provider');
|
||||
const userId = new mongoose.Types.ObjectId().toString();
|
||||
|
|
@ -2058,6 +2652,8 @@ describe('Share Methods', () => {
|
|||
messages: [message._id],
|
||||
});
|
||||
|
||||
const published = await SharedLink.findOne({ shareId }).lean();
|
||||
|
||||
const result = await shareMethods.getSharedMessages(shareId);
|
||||
const file = (result?.messages[0].files?.[0] ?? {}) as Record<string, unknown>;
|
||||
expect(file.filepath).toBe(`/api/share/${shareId}/files/${docId}`);
|
||||
|
|
@ -2065,6 +2661,10 @@ describe('Share Methods', () => {
|
|||
// snapshot persisted by the lazy backfill
|
||||
const saved = await SharedLink.findOne({ shareId }).lean();
|
||||
expect(saved?.fileSnapshots).toHaveLength(1);
|
||||
// The migration must not look like a republish: `updatedAt` is the revision a
|
||||
// viewer's fork is validated against.
|
||||
expect(saved?.updatedAt?.getTime()).toBe(published?.updatedAt?.getTime());
|
||||
expect(result?.updatedAt?.getTime()).toBe(published?.updatedAt?.getTime());
|
||||
});
|
||||
|
||||
test('does not snapshot transient text-source files', async () => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { nanoid } from 'nanoid';
|
||||
import { Types } from 'mongoose';
|
||||
import { Constants, ContentTypes, FileSources } from 'librechat-data-provider';
|
||||
import type { FilterQuery, Model } from 'mongoose';
|
||||
import type { SchemaWithMeiliMethods } from '~/models/plugins/mongoMeili';
|
||||
import type * as t from '~/types';
|
||||
import { activeExpirationFilter } from '~/utils/retention';
|
||||
import { isValidObjectIdString } from '~/utils/objectId';
|
||||
import logger from '~/config/winston';
|
||||
|
||||
class ShareServiceError extends Error {
|
||||
|
|
@ -15,6 +17,41 @@ class ShareServiceError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
type ShareOrder = Pick<t.ISharedLink, '_id' | 'createdAt'>;
|
||||
|
||||
const isEarlierShare = (candidate: ShareOrder, subject: ShareOrder): boolean => {
|
||||
const candidateTime = candidate.createdAt?.getTime() ?? 0;
|
||||
const subjectTime = subject.createdAt?.getTime() ?? 0;
|
||||
if (candidateTime !== subjectTime) {
|
||||
return candidateTime < subjectTime;
|
||||
}
|
||||
return String(candidate._id) < String(subject._id);
|
||||
};
|
||||
|
||||
/**
|
||||
* `createSharedLink` checks for an existing share and inserts in two round trips, so two
|
||||
* concurrent creates can both clear the check. Re-reading after the insert closes that:
|
||||
* every racer that sees an earlier rival retracts its own document, and since they all
|
||||
* evaluate the same total order (createdAt, then `_id`), exactly one survives.
|
||||
*/
|
||||
async function findOlderActiveShare(
|
||||
SharedLink: Model<t.ISharedLink>,
|
||||
created: ShareOrder,
|
||||
key: { conversationId: string; user: string; targetMessageId?: string },
|
||||
): Promise<boolean> {
|
||||
const rivals = (await SharedLink.find({
|
||||
conversationId: key.conversationId,
|
||||
user: key.user,
|
||||
_id: { $ne: created._id },
|
||||
...activeExpirationFilter<t.ISharedLink>(),
|
||||
...(key.targetMessageId && { targetMessageId: key.targetMessageId }),
|
||||
})
|
||||
.select('_id createdAt')
|
||||
.lean()) as ShareOrder[];
|
||||
|
||||
return rivals.some((rival) => isEarlierShare(rival, created));
|
||||
}
|
||||
|
||||
function memoizedAnonymizeId(prefix: string) {
|
||||
const memo = new Map<string, string>();
|
||||
return (id: string) => {
|
||||
|
|
@ -25,23 +62,6 @@ function memoizedAnonymizeId(prefix: string) {
|
|||
};
|
||||
}
|
||||
|
||||
const anonymizeConvoId = memoizedAnonymizeId('convo');
|
||||
const anonymizeAssistantId = memoizedAnonymizeId('a');
|
||||
const anonymizeMessageId = (id: string) =>
|
||||
id === Constants.NO_PARENT ? id : memoizedAnonymizeId('msg')(id);
|
||||
|
||||
function anonymizeConvo(conversation: Partial<t.IConversation> & Partial<t.ISharedLink>) {
|
||||
if (!conversation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const newConvo = { ...conversation };
|
||||
if (newConvo.assistant_id) {
|
||||
newConvo.assistant_id = anonymizeAssistantId(newConvo.assistant_id);
|
||||
}
|
||||
return newConvo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage- and identity-internal fields that must never be exposed through a
|
||||
* public shared link. Everything else on a file/attachment — including the
|
||||
|
|
@ -207,6 +227,112 @@ async function buildFileSnapshots(
|
|||
return snapshots;
|
||||
}
|
||||
|
||||
type SharedLinksCursor = { primary: string | null; id: string };
|
||||
|
||||
/**
|
||||
* The list cursor carries the sort value *and* the `_id` that broke its tie, base64
|
||||
* encoded so callers treat it as opaque. Older plain-value cursors decode to null and
|
||||
* fall back to the single-field boundary they were issued under.
|
||||
*/
|
||||
function decodeSharedLinksCursor(pageParam: Date | string): SharedLinksCursor | null {
|
||||
if (typeof pageParam !== 'string') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const decoded = JSON.parse(Buffer.from(pageParam, 'base64').toString());
|
||||
const hasPrimary = typeof decoded?.primary === 'string' || decoded?.primary === null;
|
||||
if (hasPrimary && isValidObjectIdString(decoded?.id)) {
|
||||
return decoded as SharedLinksCursor;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clauses that resume exactly after the boundary row. `title` is optional on a share,
|
||||
* and BSON orders null/missing before every string, so a titleless boundary cannot be
|
||||
* expressed as a comparison against `''`: ascending would skip the remaining titleless
|
||||
* rows and descending would re-admit all of them.
|
||||
*/
|
||||
function buildSharedLinksCursorClauses(
|
||||
cursor: SharedLinksCursor,
|
||||
sortBy: string,
|
||||
descending: boolean,
|
||||
): FilterQuery<t.ISharedLink>[] {
|
||||
const op = descending ? '$lt' : '$gt';
|
||||
const boundaryId = { [op]: new Types.ObjectId(cursor.id) };
|
||||
|
||||
if (cursor.primary === null) {
|
||||
/* Descending puts the titleless rows last, so only their own tail remains;
|
||||
ascending puts them first, so every titled row still follows. */
|
||||
return descending
|
||||
? [{ [sortBy]: null, _id: boundaryId } as FilterQuery<t.ISharedLink>]
|
||||
: [
|
||||
{ [sortBy]: null, _id: boundaryId } as FilterQuery<t.ISharedLink>,
|
||||
{ [sortBy]: { $ne: null } } as FilterQuery<t.ISharedLink>,
|
||||
];
|
||||
}
|
||||
|
||||
const primaryValue = sortBy === 'createdAt' ? new Date(cursor.primary) : cursor.primary;
|
||||
const clauses: FilterQuery<t.ISharedLink>[] = [
|
||||
{ [sortBy]: { [op]: primaryValue } } as FilterQuery<t.ISharedLink>,
|
||||
{ [sortBy]: primaryValue, _id: boundaryId } as FilterQuery<t.ISharedLink>,
|
||||
];
|
||||
|
||||
/* `$lt`/`$gt` are type-bracketed: compared against a string they never match a
|
||||
missing field. Descending sorts those rows after every title, so they need a
|
||||
clause of their own or the page after the last title comes back empty. */
|
||||
if (descending && typeof primaryValue === 'string') {
|
||||
clauses.push({ [sortBy]: null } as FilterQuery<t.ISharedLink>);
|
||||
}
|
||||
|
||||
return clauses;
|
||||
}
|
||||
|
||||
function encodeSharedLinksCursor(link: t.ISharedLink, sortBy: string): string {
|
||||
const value = link[sortBy as keyof t.ISharedLink];
|
||||
let primary: string | null = null;
|
||||
if (value instanceof Date) {
|
||||
primary = value.toISOString();
|
||||
} else if (value != null) {
|
||||
primary = String(value);
|
||||
}
|
||||
const composite: SharedLinksCursor = { primary, id: String(link._id) };
|
||||
return Buffer.from(JSON.stringify(composite)).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit a lazy snapshot backfill only while the link still has none. An owner can
|
||||
* republish the same shareId while a viewer's first read is in flight, and an
|
||||
* unconditional write would restore the snapshot that republish just replaced,
|
||||
* re-authorizing the stable URL of a file they removed. The stored snapshot wins any
|
||||
* race; `timestamps: false` keeps a migration from looking like a publication, since
|
||||
* `updatedAt` is the revision a viewer's fork request is validated against.
|
||||
*/
|
||||
async function persistBackfilledSnapshots(
|
||||
SharedLink: Model<t.ISharedLink>,
|
||||
filter: FilterQuery<t.ISharedLink>,
|
||||
fileSnapshots: t.SharedFileSnapshot[],
|
||||
): Promise<t.SharedFileSnapshot[]> {
|
||||
const result = await SharedLink.updateOne(
|
||||
{ ...filter, fileSnapshots: { $exists: false }, snapshotFiles: { $ne: false } },
|
||||
{ $set: { fileSnapshots } },
|
||||
{ timestamps: false },
|
||||
);
|
||||
|
||||
if (result.modifiedCount > 0) {
|
||||
return fileSnapshots;
|
||||
}
|
||||
|
||||
const current = await SharedLink.findOne(filter).select('fileSnapshots snapshotFiles').lean();
|
||||
if (!current || current.snapshotFiles === false) {
|
||||
return [];
|
||||
}
|
||||
return current.fileSnapshots ?? [];
|
||||
}
|
||||
|
||||
/** Share-scoped file route that serves a snapshotted file independent of owner ACL. */
|
||||
function shareFileRoute(shareId: string, fileId: string): string {
|
||||
return `/api/share/${shareId}/files/${encodeURIComponent(fileId)}`;
|
||||
|
|
@ -291,7 +417,10 @@ export function anonymizeSharedContent(
|
|||
* Only surface a model name when it is an (already-anonymized) assistant id;
|
||||
* otherwise omit it so the underlying provider/model is not disclosed.
|
||||
*/
|
||||
function anonymizeSharedModel(model?: string): string | undefined {
|
||||
function anonymizeSharedModel(
|
||||
model: string | undefined,
|
||||
anonymizeAssistantId: (id: string) => string,
|
||||
): string | undefined {
|
||||
if (!model?.startsWith('asst_')) {
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -312,6 +441,8 @@ function anonymizeMessages(
|
|||
shareId: string,
|
||||
snapshotIds: Set<string>,
|
||||
includeFiles: boolean,
|
||||
anonymizeMessageId: (id: string) => string,
|
||||
anonymizeAssistantId: (id: string) => string,
|
||||
): t.SharedMessage[] {
|
||||
if (!Array.isArray(messages)) {
|
||||
return [];
|
||||
|
|
@ -352,7 +483,7 @@ function anonymizeMessages(
|
|||
),
|
||||
)
|
||||
: undefined;
|
||||
const model = anonymizeSharedModel(message.model);
|
||||
const model = anonymizeSharedModel(message.model, anonymizeAssistantId);
|
||||
|
||||
return {
|
||||
messageId: newMessageId,
|
||||
|
|
@ -387,6 +518,108 @@ function anonymizeMessages(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* An update omits the target when the share dialog cannot resolve the conversation's own
|
||||
* branch tail, which is every update started from the conversation list rather than the
|
||||
* open pane. The stored target is the tail as of the last publish, so reusing it verbatim
|
||||
* would republish the identical snapshot and silently drop the turns added since.
|
||||
*
|
||||
* Walking forward to the newest descendant is the same move an update from the open pane
|
||||
* already makes by sending the live tail, so both entry points now publish the newer turns.
|
||||
* Note `getMessagesUpToTarget` bounds the snapshot by the target's *level*, not by a single
|
||||
* path, so this widens the shared depth; it stays bounded by the branch's own tail rather
|
||||
* than clearing the target, which would drop the bound entirely.
|
||||
*
|
||||
* A regeneration or edit replaces the target with a *sibling* rather than a child, so the
|
||||
* turns that follow hang off the replacement. Descendants alone would leave the walk parked
|
||||
* on the obsolete branch and publish none of them, so a childless target hops once to the
|
||||
* newest sibling that the conversation actually continued under.
|
||||
*/
|
||||
function advanceTargetToBranchTail(messages: t.IMessage[], targetMessageId: string): string {
|
||||
const messagesById = new Map<string, t.IMessage>();
|
||||
const childrenByParent = new Map<string, t.IMessage[]>();
|
||||
for (const message of messages) {
|
||||
messagesById.set(message.messageId, message);
|
||||
const parentMessageId = message.parentMessageId;
|
||||
if (!parentMessageId) {
|
||||
continue;
|
||||
}
|
||||
const siblings = childrenByParent.get(parentMessageId);
|
||||
if (siblings) {
|
||||
siblings.push(message);
|
||||
continue;
|
||||
}
|
||||
childrenByParent.set(parentMessageId, [message]);
|
||||
}
|
||||
|
||||
const newestOf = (candidates: t.IMessage[]): t.IMessage | undefined => {
|
||||
let newest: t.IMessage | undefined;
|
||||
for (const candidate of candidates) {
|
||||
const candidateTime = candidate.createdAt?.getTime() ?? 0;
|
||||
const newestTime = newest?.createdAt?.getTime() ?? 0;
|
||||
if (!newest || candidateTime > newestTime) {
|
||||
newest = candidate;
|
||||
}
|
||||
}
|
||||
return newest;
|
||||
};
|
||||
|
||||
const replacementFor = (messageId: string): t.IMessage | undefined => {
|
||||
const node = messagesById.get(messageId);
|
||||
const parentMessageId = node?.parentMessageId;
|
||||
if (!parentMessageId) {
|
||||
return undefined;
|
||||
}
|
||||
/* Only a sibling created after this one can be its replacement. An older sibling
|
||||
that happens to have follow-ups is the branch this one was regenerated away
|
||||
from, and resuming there would publish turns the target deliberately excluded. */
|
||||
const nodeTime = node?.createdAt?.getTime() ?? 0;
|
||||
const continued = (childrenByParent.get(parentMessageId) ?? []).filter(
|
||||
(sibling) =>
|
||||
sibling.messageId !== messageId &&
|
||||
childrenByParent.has(sibling.messageId) &&
|
||||
(sibling.createdAt?.getTime() ?? 0) > nodeTime,
|
||||
);
|
||||
return newestOf(continued);
|
||||
};
|
||||
|
||||
/** The regenerated message is not always the stored target: regenerating an answer
|
||||
* further up leaves the whole stored branch childless while the conversation carries
|
||||
* on under the replacement. Climb until a level offers one, so the walk resumes at
|
||||
* the closest point where the branch actually diverged. */
|
||||
const findBranchReplacement = (): t.IMessage | undefined => {
|
||||
const climbed = new Set<string>();
|
||||
let node: string | undefined = targetMessageId;
|
||||
while (node && !climbed.has(node)) {
|
||||
climbed.add(node);
|
||||
const replacement = replacementFor(node);
|
||||
if (replacement) {
|
||||
return replacement;
|
||||
}
|
||||
node = messagesById.get(node)?.parentMessageId ?? undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
let current = targetMessageId;
|
||||
const visited = new Set([current]);
|
||||
for (;;) {
|
||||
const children = childrenByParent.get(current);
|
||||
let next: t.IMessage | undefined;
|
||||
if (children?.length) {
|
||||
next = newestOf(children);
|
||||
} else if (current === targetMessageId) {
|
||||
next = findBranchReplacement();
|
||||
}
|
||||
|
||||
if (!next?.messageId || visited.has(next.messageId)) {
|
||||
return current;
|
||||
}
|
||||
visited.add(next.messageId);
|
||||
current = next.messageId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter messages up to and including the target message (branch-specific)
|
||||
* Similar to getMessagesUpToTargetLevel from fork utilities
|
||||
|
|
@ -414,14 +647,16 @@ function getMessagesUpToTarget(messages: t.IMessage[], targetMessageId: string):
|
|||
// Find the target message
|
||||
const targetMessage = messages.find((msg) => msg.messageId === targetMessageId);
|
||||
if (!targetMessage) {
|
||||
// If target not found, return all messages for backwards compatibility
|
||||
return messages;
|
||||
// Fail closed: a stale or malformed target must never widen the share from a
|
||||
// selected branch/level to the entire conversation.
|
||||
return [];
|
||||
}
|
||||
|
||||
const visited = new Set<string>();
|
||||
const rootMessages = parentToChildrenMap.get(Constants.NO_PARENT) || [];
|
||||
let currentLevel = rootMessages.length > 0 ? [...rootMessages] : [targetMessage];
|
||||
const results = new Set<t.IMessage>(currentLevel);
|
||||
let targetFound = currentLevel.some((msg) => msg.messageId === targetMessageId);
|
||||
|
||||
// Check if the target message is at the root level
|
||||
if (
|
||||
|
|
@ -432,7 +667,6 @@ function getMessagesUpToTarget(messages: t.IMessage[], targetMessageId: string):
|
|||
}
|
||||
|
||||
// Iterate level by level until the target is found
|
||||
let targetFound = false;
|
||||
while (!targetFound && currentLevel.length > 0) {
|
||||
const nextLevel: t.IMessage[] = [];
|
||||
for (const node of currentLevel) {
|
||||
|
|
@ -455,6 +689,13 @@ function getMessagesUpToTarget(messages: t.IMessage[], targetMessageId: string):
|
|||
currentLevel = nextLevel;
|
||||
}
|
||||
|
||||
// Fail closed: an orphaned target (an import or a partial delete broke its parent
|
||||
// chain) is never reached from the roots, and returning the levels accumulated on
|
||||
// the way would publish the whole conversation instead of the selected branch.
|
||||
if (!targetFound) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(results);
|
||||
}
|
||||
|
||||
|
|
@ -463,7 +704,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
|
|||
getSharedLink: (user: string, conversationId: string) => Promise<t.GetShareLinkResult>;
|
||||
getSharedLinks: (
|
||||
user: string,
|
||||
pageParam?: Date,
|
||||
pageParam?: Date | string,
|
||||
pageSize?: number,
|
||||
sortBy?: string,
|
||||
sortDirection?: string,
|
||||
|
|
@ -537,6 +778,14 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
|
|||
messagesToShare = getMessagesUpToTarget(share.messages, share.targetMessageId);
|
||||
}
|
||||
|
||||
// Keep anonymous ids consistent within a response without retaining a
|
||||
// process-global map. Global maps let viewers correlate the same private
|
||||
// conversation/assistant across distinct links and grow without bounds.
|
||||
const anonymizeConvoId = memoizedAnonymizeId('convo');
|
||||
const anonymizeAssistantId = memoizedAnonymizeId('a');
|
||||
const memoizedMessageId = memoizedAnonymizeId('msg');
|
||||
const anonymizeMessageId = (id: string) =>
|
||||
!id || id === Constants.NO_PARENT ? Constants.NO_PARENT : memoizedMessageId(id);
|
||||
const newConvoId = anonymizeConvoId(share.conversationId);
|
||||
const resolvedShareId = share.shareId || shareId;
|
||||
|
||||
|
|
@ -552,8 +801,11 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
|
|||
const includeFiles = adminEnabled && perLinkEnabled;
|
||||
let fileSnapshots = share.fileSnapshots;
|
||||
if (includeFiles && fileSnapshots === undefined && share._id) {
|
||||
fileSnapshots = await buildFileSnapshots(mongoose, messagesToShare, share.user);
|
||||
await SharedLink.updateOne({ _id: share._id }, { $set: { fileSnapshots } });
|
||||
fileSnapshots = await persistBackfilledSnapshots(
|
||||
SharedLink,
|
||||
{ _id: share._id },
|
||||
await buildFileSnapshots(mongoose, messagesToShare, share.user),
|
||||
);
|
||||
}
|
||||
const snapshotIds = includeFiles
|
||||
? new Set<string>((fileSnapshots ?? []).map((snapshot) => snapshot.file_id))
|
||||
|
|
@ -570,6 +822,8 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
|
|||
resolvedShareId,
|
||||
snapshotIds,
|
||||
includeFiles,
|
||||
anonymizeMessageId,
|
||||
anonymizeAssistantId,
|
||||
),
|
||||
};
|
||||
|
||||
|
|
@ -588,7 +842,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
|
|||
*/
|
||||
async function getSharedLinks(
|
||||
user: string,
|
||||
pageParam?: Date,
|
||||
pageParam?: Date | string,
|
||||
pageSize: number = 10,
|
||||
sortBy: string = 'createdAt',
|
||||
sortDirection: string = 'desc',
|
||||
|
|
@ -603,10 +857,19 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
|
|||
};
|
||||
|
||||
if (pageParam) {
|
||||
if (sortDirection === 'desc') {
|
||||
query[sortBy] = { $lt: pageParam };
|
||||
const op = sortDirection === 'desc' ? '$lt' : '$gt';
|
||||
const cursor = decodeSharedLinksCursor(pageParam);
|
||||
if (cursor) {
|
||||
/* Titles repeat and createdAt can collide, so a single-field boundary drops
|
||||
every row that ties with the last one on the previous page. `_id` breaks
|
||||
the tie. Nested under `$and` because the expiration filter owns `$or`. */
|
||||
query.$and = [
|
||||
{
|
||||
$or: buildSharedLinksCursorClauses(cursor, sortBy, sortDirection === 'desc'),
|
||||
} as FilterQuery<t.ISharedLink>,
|
||||
];
|
||||
} else {
|
||||
query[sortBy] = { $gt: pageParam };
|
||||
query[sortBy] = { [op]: pageParam };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -641,6 +904,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
|
|||
|
||||
const sort: Record<string, 1 | -1> = {};
|
||||
sort[sortBy] = sortDirection === 'desc' ? -1 : 1;
|
||||
sort._id = sort[sortBy];
|
||||
|
||||
const sharedLinks = await SharedLink.find(query)
|
||||
.sort(sort)
|
||||
|
|
@ -652,7 +916,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
|
|||
const links = sharedLinks.slice(0, pageSize);
|
||||
|
||||
const nextCursor = hasNextPage
|
||||
? (links[links.length - 1][sortBy as keyof t.ISharedLink] as Date)
|
||||
? encodeSharedLinksCursor(links[links.length - 1], sortBy)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
|
|
@ -786,6 +1050,13 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
|
|||
throw new ShareServiceError('No messages to share', 'NO_MESSAGES');
|
||||
}
|
||||
|
||||
if (
|
||||
targetMessageId &&
|
||||
!conversationMessages.some((message) => message.messageId === targetMessageId)
|
||||
) {
|
||||
throw new ShareServiceError('Target message not found', 'TARGET_MESSAGE_NOT_FOUND');
|
||||
}
|
||||
|
||||
const title = conversation.title || 'Untitled';
|
||||
|
||||
const messagesForSnapshot = conversationMessages as unknown as t.IMessage[];
|
||||
|
|
@ -812,6 +1083,21 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
|
|||
...(snapshotFiles && { fileSnapshots }),
|
||||
});
|
||||
|
||||
const supersededBy = await findOlderActiveShare(SharedLink, created, {
|
||||
conversationId,
|
||||
user,
|
||||
targetMessageId,
|
||||
});
|
||||
if (supersededBy) {
|
||||
await SharedLink.deleteOne({ _id: created._id });
|
||||
logger.warn('[createSharedLink] Concurrent create lost to an earlier share', {
|
||||
user,
|
||||
conversationId,
|
||||
targetMessageId,
|
||||
});
|
||||
throw new ShareServiceError('Share already exists', 'SHARE_EXISTS');
|
||||
}
|
||||
|
||||
return { _id: created._id.toString(), shareId, conversationId, targetMessageId };
|
||||
} catch (error) {
|
||||
if (error instanceof ShareServiceError) {
|
||||
|
|
@ -904,9 +1190,26 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
|
|||
.sort({ createdAt: 1 })
|
||||
.lean();
|
||||
|
||||
const newShareId = nanoid();
|
||||
if (updatedMessages.length === 0) {
|
||||
throw new ShareServiceError('No messages to share', 'NO_MESSAGES');
|
||||
}
|
||||
|
||||
const hasNewExpiration = expiredAt instanceof Date;
|
||||
const resolvedTargetMessageId = targetMessageId ?? share.targetMessageId;
|
||||
const storedTargetMessageId = targetMessageId ?? share.targetMessageId;
|
||||
if (
|
||||
storedTargetMessageId &&
|
||||
!updatedMessages.some((message) => message.messageId === storedTargetMessageId)
|
||||
) {
|
||||
throw new ShareServiceError('Target message not found', 'TARGET_MESSAGE_NOT_FOUND');
|
||||
}
|
||||
const resolvedTargetMessageId =
|
||||
targetMessageId ??
|
||||
(storedTargetMessageId
|
||||
? advanceTargetToBranchTail(
|
||||
updatedMessages as unknown as t.IMessage[],
|
||||
storedTargetMessageId,
|
||||
)
|
||||
: undefined);
|
||||
const messagesForSnapshot = updatedMessages as unknown as t.IMessage[];
|
||||
const fileSnapshots = snapshotFiles
|
||||
? await buildFileSnapshots(
|
||||
|
|
@ -927,7 +1230,6 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
|
|||
$set: {
|
||||
messages: updatedMessages,
|
||||
user,
|
||||
shareId: newShareId,
|
||||
snapshotFiles,
|
||||
...(resolvedTargetMessageId && { targetMessageId: resolvedTargetMessageId }),
|
||||
...(hasNewExpiration && { expiredAt }),
|
||||
|
|
@ -946,11 +1248,9 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
|
|||
throw new ShareServiceError('Share update failed', 'SHARE_UPDATE_ERROR');
|
||||
}
|
||||
|
||||
anonymizeConvo(updatedShare);
|
||||
|
||||
return {
|
||||
_id: updatedShare._id?.toString(),
|
||||
shareId: newShareId,
|
||||
shareId,
|
||||
conversationId: updatedShare.conversationId,
|
||||
targetMessageId: updatedShare.targetMessageId,
|
||||
};
|
||||
|
|
@ -1059,8 +1359,11 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
|
|||
messages = getMessagesUpToTarget(messages, share.targetMessageId);
|
||||
}
|
||||
|
||||
const fileSnapshots = await buildFileSnapshots(mongoose, messages, share.user);
|
||||
await SharedLink.updateOne({ shareId }, { $set: { fileSnapshots } });
|
||||
const fileSnapshots = await persistBackfilledSnapshots(
|
||||
SharedLink,
|
||||
{ shareId },
|
||||
await buildFileSnapshots(mongoose, messages, share.user),
|
||||
);
|
||||
|
||||
if (fileId) {
|
||||
return fileSnapshots.find((snapshot) => snapshot.file_id === fileId) ?? null;
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ export interface IConversation extends Document {
|
|||
stop?: string[];
|
||||
isArchived?: boolean;
|
||||
pinned?: boolean;
|
||||
/** Derived per request from the shared-links collection; never persisted on the conversation. */
|
||||
isShared?: boolean;
|
||||
iconURL?: string;
|
||||
greeting?: string;
|
||||
spec?: string;
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ export interface SharedLinksResult {
|
|||
createdAt: Date;
|
||||
conversationId: string;
|
||||
}>;
|
||||
nextCursor?: Date;
|
||||
nextCursor?: Date | string;
|
||||
hasNextPage: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue