snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
@@ -0,0 +1,535 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useDraft } from './useDraft';
|
||||
|
||||
const storageKey = (rid: string, tmid?: string) => `messagebox_${rid}${tmid ? `-${tmid}` : ''}`;
|
||||
|
||||
const seedLocalDraft = (value: string, rid: string, tmid?: string) => {
|
||||
localStorage.setItem(storageKey(rid, tmid), value);
|
||||
};
|
||||
|
||||
const readLocalDraft = (rid: string, tmid?: string) => localStorage.getItem(storageKey(rid, tmid));
|
||||
|
||||
type RenderUseDraftProps = {
|
||||
serverDraft?: string;
|
||||
threadExists?: boolean;
|
||||
};
|
||||
|
||||
const renderUseDraft = ({
|
||||
rid = 'rid',
|
||||
serverDraft,
|
||||
tmid,
|
||||
threadExists,
|
||||
endpointHandler = jest.fn(() => null),
|
||||
}: {
|
||||
rid?: string;
|
||||
tmid?: string;
|
||||
endpointHandler?: jest.Mock;
|
||||
} & RenderUseDraftProps = {}) => {
|
||||
const { result, rerender } = renderHook<ReturnType<typeof useDraft>, RenderUseDraftProps>(
|
||||
({ serverDraft, threadExists }) => useDraft(rid, serverDraft, tmid, threadExists),
|
||||
{
|
||||
initialProps: { serverDraft, threadExists },
|
||||
wrapper: mockAppRoot().withEndpoint('POST', '/v1/rooms.saveDraft', endpointHandler).build(),
|
||||
},
|
||||
);
|
||||
|
||||
return { result, rerender, endpointHandler };
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('initialValue', () => {
|
||||
it('should be an empty string when there is no server draft nor local draft', () => {
|
||||
const { result } = renderUseDraft();
|
||||
|
||||
expect(result.current.initialValue).toBe('');
|
||||
});
|
||||
|
||||
it('should restore the local draft when there is no server draft', () => {
|
||||
seedLocalDraft('local draft', 'rid');
|
||||
|
||||
const { result } = renderUseDraft();
|
||||
|
||||
expect(result.current.initialValue).toBe('local draft');
|
||||
});
|
||||
|
||||
it('should restore the server draft when provided', () => {
|
||||
const { result } = renderUseDraft({ serverDraft: 'server draft' });
|
||||
|
||||
expect(result.current.initialValue).toBe('server draft');
|
||||
});
|
||||
|
||||
it('should prefer the server draft over the local draft', () => {
|
||||
seedLocalDraft('local draft', 'rid');
|
||||
|
||||
const { result } = renderUseDraft({ serverDraft: 'server draft' });
|
||||
|
||||
expect(result.current.initialValue).toBe('server draft');
|
||||
});
|
||||
|
||||
it('should fall back to the local draft when the server draft is an empty string', () => {
|
||||
seedLocalDraft('local draft', 'rid');
|
||||
|
||||
const { result } = renderUseDraft({ serverDraft: '' });
|
||||
|
||||
expect(result.current.initialValue).toBe('local draft');
|
||||
});
|
||||
|
||||
it('should restore the thread-scoped local draft when a tmid is provided', () => {
|
||||
seedLocalDraft('room draft', 'rid');
|
||||
seedLocalDraft('thread draft', 'rid', 'tmid');
|
||||
|
||||
const { result } = renderUseDraft({ tmid: 'tmid' });
|
||||
|
||||
expect(result.current.initialValue).toBe('thread draft');
|
||||
});
|
||||
|
||||
it('should not restore the room draft in a thread composer', () => {
|
||||
seedLocalDraft('room draft', 'rid');
|
||||
|
||||
const { result } = renderUseDraft({ tmid: 'tmid' });
|
||||
|
||||
expect(result.current.initialValue).toBe('');
|
||||
});
|
||||
|
||||
it('should remain stable across rerenders even if the server draft changes', () => {
|
||||
const { result, rerender } = renderUseDraft({ serverDraft: 'first' });
|
||||
|
||||
rerender({ serverDraft: 'second' });
|
||||
|
||||
expect(result.current.initialValue).toBe('first');
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistLocal', () => {
|
||||
it('should write the draft to local storage under the room-scoped key', () => {
|
||||
const { result } = renderUseDraft();
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('typed message');
|
||||
});
|
||||
|
||||
expect(readLocalDraft('rid')).toBe('typed message');
|
||||
});
|
||||
|
||||
it('should write the draft to local storage under the thread-scoped key when a tmid is provided', () => {
|
||||
const { result } = renderUseDraft({ tmid: 'tmid' });
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('typed in thread');
|
||||
});
|
||||
|
||||
expect(readLocalDraft('rid', 'tmid')).toBe('typed in thread');
|
||||
expect(readLocalDraft('rid')).toBe(null);
|
||||
});
|
||||
|
||||
it('should overwrite a previously persisted draft', () => {
|
||||
const { result } = renderUseDraft();
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('first');
|
||||
result.current.persistLocal('second');
|
||||
});
|
||||
|
||||
expect(readLocalDraft('rid')).toBe('second');
|
||||
});
|
||||
|
||||
it('should remove the local entry instead of storing an empty string', () => {
|
||||
seedLocalDraft('local draft', 'rid');
|
||||
|
||||
const { result } = renderUseDraft();
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('');
|
||||
});
|
||||
|
||||
expect(readLocalDraft('rid')).toBe(null);
|
||||
});
|
||||
|
||||
it('should not call the save draft endpoint', () => {
|
||||
const { result, endpointHandler } = renderUseDraft();
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('typed message');
|
||||
});
|
||||
|
||||
expect(endpointHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('flushDraft', () => {
|
||||
it('should not call the save draft endpoint when no draft was persisted', () => {
|
||||
const { result, endpointHandler } = renderUseDraft();
|
||||
|
||||
act(() => {
|
||||
result.current.flushDraft();
|
||||
});
|
||||
|
||||
expect(endpointHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not flush a draft restored from local storage that was never re-persisted', () => {
|
||||
seedLocalDraft('restored draft', 'rid');
|
||||
|
||||
const { result, endpointHandler } = renderUseDraft();
|
||||
|
||||
act(() => {
|
||||
result.current.flushDraft();
|
||||
});
|
||||
|
||||
expect(endpointHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should save the persisted draft to the server', async () => {
|
||||
const { result, endpointHandler } = renderUseDraft();
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('typed message');
|
||||
result.current.flushDraft();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
|
||||
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'typed message' });
|
||||
});
|
||||
|
||||
it('should remove the local copy once the draft is saved to the server', async () => {
|
||||
const { result } = renderUseDraft();
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('typed message');
|
||||
result.current.flushDraft();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(readLocalDraft('rid')).toBe(null));
|
||||
});
|
||||
|
||||
it('should include the tmid when flushing a thread draft', async () => {
|
||||
const { result, endpointHandler } = renderUseDraft({ tmid: 'tmid' });
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('typed in thread');
|
||||
result.current.flushDraft();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
|
||||
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'typed in thread', tmid: 'tmid' });
|
||||
});
|
||||
|
||||
it('should flush only the latest persisted draft', async () => {
|
||||
const { result, endpointHandler } = renderUseDraft();
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('first');
|
||||
result.current.persistLocal('second');
|
||||
result.current.flushDraft();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
|
||||
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'second' });
|
||||
});
|
||||
|
||||
it('should flush an empty draft to clear an existing server draft', async () => {
|
||||
const { result, endpointHandler } = renderUseDraft({ serverDraft: 'server draft' });
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('');
|
||||
result.current.flushDraft();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
|
||||
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: '' });
|
||||
});
|
||||
|
||||
it('should not call the endpoint when flushing an empty draft with no existing server draft', () => {
|
||||
const { result, endpointHandler } = renderUseDraft();
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('');
|
||||
result.current.flushDraft();
|
||||
});
|
||||
|
||||
expect(endpointHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not re-save a restored server draft that was not edited', () => {
|
||||
const { result, endpointHandler } = renderUseDraft({ serverDraft: 'server draft' });
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('server draft');
|
||||
result.current.flushDraft();
|
||||
});
|
||||
|
||||
expect(endpointHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not flush the same draft twice', async () => {
|
||||
const { result, endpointHandler } = renderUseDraft();
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('typed message');
|
||||
result.current.flushDraft();
|
||||
result.current.flushDraft();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('should flush again after a new draft is persisted', async () => {
|
||||
const { result, endpointHandler } = renderUseDraft();
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('first');
|
||||
result.current.flushDraft();
|
||||
result.current.persistLocal('second');
|
||||
result.current.flushDraft();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(2));
|
||||
expect(endpointHandler).toHaveBeenNthCalledWith(1, { rid: 'rid', draft: 'first' });
|
||||
expect(endpointHandler).toHaveBeenNthCalledWith(2, { rid: 'rid', draft: 'second' });
|
||||
});
|
||||
|
||||
it('should not save the draft to the server when the thread does not exist', () => {
|
||||
const { result, endpointHandler } = renderUseDraft({ tmid: 'tmid', threadExists: false });
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('typed in deleted thread');
|
||||
result.current.flushDraft();
|
||||
});
|
||||
|
||||
expect(endpointHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should keep the draft in local storage when the thread does not exist', () => {
|
||||
const { result } = renderUseDraft({ tmid: 'tmid', threadExists: false });
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('typed in deleted thread');
|
||||
result.current.flushDraft();
|
||||
});
|
||||
|
||||
expect(readLocalDraft('rid', 'tmid')).toBe('typed in deleted thread');
|
||||
});
|
||||
|
||||
it('should keep discarding drafts persisted after a flush while the thread does not exist', () => {
|
||||
const { result, endpointHandler } = renderUseDraft({ tmid: 'tmid', threadExists: false });
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('first');
|
||||
result.current.flushDraft();
|
||||
result.current.persistLocal('second');
|
||||
result.current.flushDraft();
|
||||
});
|
||||
|
||||
expect(endpointHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not save the draft when the thread stops existing after mount', () => {
|
||||
const { result, rerender, endpointHandler } = renderUseDraft({ tmid: 'tmid', threadExists: true });
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('typed before thread deletion');
|
||||
});
|
||||
|
||||
rerender({ threadExists: false });
|
||||
|
||||
act(() => {
|
||||
result.current.flushDraft();
|
||||
});
|
||||
|
||||
expect(endpointHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should save the draft when the thread starts existing after mount', async () => {
|
||||
const { result, rerender, endpointHandler } = renderUseDraft({ tmid: 'tmid', threadExists: false });
|
||||
|
||||
rerender({ threadExists: true });
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('typed in new thread');
|
||||
result.current.flushDraft();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
|
||||
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'typed in new thread', tmid: 'tmid' });
|
||||
});
|
||||
|
||||
it('should still save the draft when threadExists is false but there is no tmid', async () => {
|
||||
const { result, endpointHandler } = renderUseDraft({ threadExists: false });
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('typed message');
|
||||
result.current.flushDraft();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
|
||||
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'typed message' });
|
||||
});
|
||||
|
||||
it('should flush the given value instead of the persisted draft', async () => {
|
||||
const { result, endpointHandler } = renderUseDraft();
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('typed message');
|
||||
result.current.flushDraft('edited message');
|
||||
});
|
||||
|
||||
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
|
||||
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'edited message' });
|
||||
});
|
||||
|
||||
it('should clear the server draft when flushed with an empty value', async () => {
|
||||
const { result, endpointHandler } = renderUseDraft({ serverDraft: 'server draft' });
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('typed message');
|
||||
result.current.flushDraft('');
|
||||
});
|
||||
|
||||
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
|
||||
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: '' });
|
||||
});
|
||||
|
||||
it('should clear the server thread draft when flushed with an empty value', async () => {
|
||||
const { result, endpointHandler } = renderUseDraft({ tmid: 'tmid', serverDraft: 'server draft' });
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('typed in thread');
|
||||
result.current.flushDraft('');
|
||||
});
|
||||
|
||||
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
|
||||
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: '', tmid: 'tmid' });
|
||||
});
|
||||
|
||||
it('should remove the local copy once the draft is cleared on the server', async () => {
|
||||
const { result } = renderUseDraft({ serverDraft: 'server draft' });
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('typed message');
|
||||
result.current.flushDraft('');
|
||||
});
|
||||
|
||||
await waitFor(() => expect(readLocalDraft('rid')).toBe(null));
|
||||
});
|
||||
|
||||
it('should not call the endpoint when flushing an empty value with no server draft', () => {
|
||||
const { result, endpointHandler } = renderUseDraft();
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('typed message');
|
||||
result.current.flushDraft('');
|
||||
result.current.flushDraft('');
|
||||
});
|
||||
|
||||
expect(endpointHandler).not.toHaveBeenCalled();
|
||||
expect(readLocalDraft('rid')).toBe(null);
|
||||
});
|
||||
|
||||
it('should remove the local copy when the flushed value already matches the server draft', () => {
|
||||
const { result, endpointHandler } = renderUseDraft({ serverDraft: 'server draft' });
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('typed message');
|
||||
result.current.flushDraft('server draft');
|
||||
});
|
||||
|
||||
expect(endpointHandler).not.toHaveBeenCalled();
|
||||
expect(readLocalDraft('rid')).toBe(null);
|
||||
});
|
||||
|
||||
it('should keep a draft persisted while the previous save is still in flight', async () => {
|
||||
let resolveSave: (() => void) | undefined;
|
||||
const endpointHandler = jest.fn(
|
||||
() =>
|
||||
new Promise<null>((resolve) => {
|
||||
resolveSave = () => resolve(null);
|
||||
}),
|
||||
);
|
||||
const { result } = renderUseDraft({ serverDraft: 'server draft', endpointHandler });
|
||||
|
||||
act(() => {
|
||||
result.current.flushDraft('');
|
||||
});
|
||||
|
||||
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('typed while sending');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveSave?.();
|
||||
});
|
||||
|
||||
expect(readLocalDraft('rid')).toBe('typed while sending');
|
||||
});
|
||||
|
||||
it('should keep the local copy when the save fails', async () => {
|
||||
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
const endpointHandler = jest.fn(() => Promise.reject(new Error('save failed')) as unknown as null);
|
||||
const { result } = renderUseDraft({ endpointHandler });
|
||||
|
||||
await act(async () => {
|
||||
result.current.persistLocal('typed message');
|
||||
result.current.flushDraft();
|
||||
});
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(readLocalDraft('rid')).toBe('typed message');
|
||||
});
|
||||
|
||||
it('should retry a draft whose save failed and drop the local copy once it lands', async () => {
|
||||
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
const endpointHandler = jest.fn().mockRejectedValueOnce(new Error('save failed')).mockResolvedValueOnce(null);
|
||||
const { result } = renderUseDraft({ endpointHandler });
|
||||
|
||||
await act(async () => {
|
||||
result.current.persistLocal('typed message');
|
||||
result.current.flushDraft();
|
||||
});
|
||||
|
||||
expect(readLocalDraft('rid')).toBe('typed message');
|
||||
|
||||
await act(async () => {
|
||||
result.current.persistLocal('typed message');
|
||||
result.current.flushDraft();
|
||||
});
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(endpointHandler).toHaveBeenCalledTimes(2);
|
||||
expect(endpointHandler).toHaveBeenNthCalledWith(2, { rid: 'rid', draft: 'typed message' });
|
||||
expect(readLocalDraft('rid')).toBe(null);
|
||||
});
|
||||
|
||||
it('should not flush the persisted draft after it was superseded by an explicit flush', async () => {
|
||||
const { result, endpointHandler } = renderUseDraft();
|
||||
|
||||
act(() => {
|
||||
result.current.persistLocal('typed message');
|
||||
result.current.flushDraft('sent message');
|
||||
result.current.flushDraft();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
|
||||
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'sent message' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('referential stability', () => {
|
||||
it('should keep persistLocal and flushDraft identities across rerenders', () => {
|
||||
const { result, rerender } = renderUseDraft();
|
||||
|
||||
const { persistLocal, flushDraft } = result.current;
|
||||
|
||||
rerender({});
|
||||
|
||||
expect(result.current.persistLocal).toBe(persistLocal);
|
||||
expect(result.current.flushDraft).toBe(flushDraft);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
export const useDraft = (rid: string, serverDraft?: string, tmid?: string, threadExists = true) => {
|
||||
const storageKey = `messagebox_${rid}${tmid ? `-${tmid}` : ''}`;
|
||||
const saveDraft = useEndpoint('POST', '/v1/rooms.saveDraft');
|
||||
|
||||
const setLocalDraft = useCallback(
|
||||
(value?: string) => {
|
||||
if (value) {
|
||||
localStorage.setItem(storageKey, value);
|
||||
} else {
|
||||
localStorage.removeItem(storageKey);
|
||||
}
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const initialValueRef = useRef(serverDraft || localStorage.getItem(storageKey) || '');
|
||||
const draftRef = useRef<string | null>(null);
|
||||
const threadExistsRef = useRef(threadExists);
|
||||
const serverValueRef = useRef(serverDraft ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
threadExistsRef.current = threadExists;
|
||||
}, [threadExists]);
|
||||
|
||||
const persistLocal = useCallback(
|
||||
(value: string) => {
|
||||
draftRef.current = value;
|
||||
setLocalDraft(value);
|
||||
},
|
||||
[setLocalDraft],
|
||||
);
|
||||
|
||||
const flushDraft = useCallback(
|
||||
(value?: string) => {
|
||||
const draft = value ?? draftRef.current;
|
||||
|
||||
if (draft === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
draftRef.current = null;
|
||||
|
||||
if (tmid && !threadExistsRef.current && draft) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (draft === serverValueRef.current) {
|
||||
setLocalDraft();
|
||||
return;
|
||||
}
|
||||
|
||||
const previousServerValue = serverValueRef.current;
|
||||
serverValueRef.current = draft;
|
||||
|
||||
void saveDraft({ rid, draft, ...(tmid && { tmid }) })
|
||||
.then(() => {
|
||||
if (draftRef.current === null) {
|
||||
setLocalDraft();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (serverValueRef.current === draft) {
|
||||
serverValueRef.current = previousServerValue;
|
||||
}
|
||||
|
||||
console.warn(error);
|
||||
});
|
||||
},
|
||||
[saveDraft, rid, tmid, setLocalDraft],
|
||||
);
|
||||
|
||||
return {
|
||||
initialValue: initialValueRef.current,
|
||||
persistLocal,
|
||||
flushDraft,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const useMediaActionTitle = (
|
||||
media: 'audio' | 'video',
|
||||
isPermissionDenied: boolean,
|
||||
isFileUploadEnabled: boolean,
|
||||
isMediaEnabled: boolean,
|
||||
isAllowed: boolean,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getMediaActionTitle = useMemo(() => {
|
||||
if (isPermissionDenied) {
|
||||
return media === 'audio' ? t('Microphone_access_not_allowed') : t('Camera_access_not_allowed');
|
||||
}
|
||||
|
||||
if (!isFileUploadEnabled) {
|
||||
return t('File_Upload_Disabled');
|
||||
}
|
||||
|
||||
if (!isMediaEnabled) {
|
||||
return media === 'audio' ? t('Message_Audio_Recording_Disabled') : t('Message_Video_Recording_Disabled');
|
||||
}
|
||||
|
||||
if (!isAllowed) {
|
||||
return t('error-not-allowed');
|
||||
}
|
||||
|
||||
return media === 'audio' ? t('Audio_message') : t('Video_message');
|
||||
}, [media, isPermissionDenied, isFileUploadEnabled, isMediaEnabled, isAllowed, t]);
|
||||
|
||||
return getMediaActionTitle;
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
type MediaDevices = 'camera' | 'microphone';
|
||||
|
||||
const getDeviceKind = (name: MediaDevices): MediaDeviceKind => {
|
||||
switch (name) {
|
||||
case 'camera':
|
||||
return 'videoinput';
|
||||
case 'microphone':
|
||||
return 'audioinput';
|
||||
}
|
||||
};
|
||||
|
||||
export const useMediaPermissions = (name: MediaDevices): [isPermissionDenied: boolean, setIsPermissionDenied: (state: boolean) => void] => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const queryKey = ['media-permissions', name];
|
||||
|
||||
const setIsPermissionDenied = useStableCallback((isDenied: boolean) => {
|
||||
queryClient.setQueryData(queryKey, isDenied);
|
||||
});
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
if (navigator.permissions) {
|
||||
try {
|
||||
const permissionStatus = await navigator.permissions.query({ name: name as PermissionName });
|
||||
permissionStatus.onchange = (): void => {
|
||||
queryClient.setQueryData(queryKey, permissionStatus.state === 'denied');
|
||||
};
|
||||
return permissionStatus.state === 'denied';
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!navigator.mediaDevices?.enumerateDevices) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!(await navigator.mediaDevices.enumerateDevices()).some(({ kind }) => kind === getDeviceKind(name))) {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
}
|
||||
},
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Infinity,
|
||||
placeholderData: false,
|
||||
});
|
||||
|
||||
return [Boolean(data), setIsPermissionDenied];
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Ref } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* if the user is types outside the message box and its not actually typing in any input field
|
||||
* then the message box should be focused
|
||||
* @returns callbackRef to bind the logic to the message box
|
||||
*/
|
||||
export const useMessageBoxAutoFocus = (enabled: boolean): Ref<HTMLElement> => {
|
||||
const ref = useRef<HTMLElement>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const { current: node } = ref;
|
||||
const { target } = e;
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target === node) {
|
||||
return;
|
||||
}
|
||||
if (!((e.keyCode > 45 && e.keyCode < 91) || e.keyCode === 8)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (/input|textarea|select/i.test((target as HTMLElement).tagName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.ctrlKey === true || e.metaKey === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
node?.focus();
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.current = node;
|
||||
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ref.current) {
|
||||
ref.current.focus();
|
||||
}
|
||||
},
|
||||
[enabled, ref],
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { isDirectMessageRoom } from '@rocket.chat/core-typings';
|
||||
|
||||
import { useRoomName } from '../../../../../hooks/useRoomName';
|
||||
|
||||
export const useMessageBoxPlaceholder = (placeholder: string, room?: IRoom) => {
|
||||
if (!room) {
|
||||
throw new Error('In order to get the placeholder a `room` must be provided');
|
||||
}
|
||||
|
||||
const roomName = useRoomName(room);
|
||||
|
||||
if (isDirectMessageRoom(room)) {
|
||||
return `${placeholder} @${roomName}`;
|
||||
}
|
||||
|
||||
return `${placeholder} #${roomName}`;
|
||||
};
|
||||
Reference in New Issue
Block a user