-
Notifications
You must be signed in to change notification settings - Fork 296
feat: integrate Collabora using iframe [WPB-21650] #19813
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Conversation
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR integrates Collabora Online for real-time collaborative document editing via iframe embedding. Users can now edit supported file types (docx, xlsx, pptx, odf) directly within the Wire webapp through a toggle between "Viewing" and "Editing" modes in the file preview modal.
Key Changes:
- Added
FileEditorcomponent that renders Collabora URLs in an iframe with auto-refresh logic before URL expiry - Extended file preview modals with edit mode state management and UI toggle buttons
- Implemented
isFileEditable()utility to determine which file extensions support collaborative editing - Propagated asset IDs (UUIDs) through component hierarchy replacing React
useId()for consistent identification
Reviewed changes
Copilot reviewed 30 out of 31 changed files in this pull request and generated 19 comments.
Show a summary per file
| File | Description |
|---|---|
src/script/components/FileFullscreenModal/FileEditor/FileEditor.tsx |
New component rendering Collabora iframe with URL refresh logic |
src/script/components/FileFullscreenModal/FileEditor/FileEditor.styles.ts |
Styles for the iframe editor (full width/height) |
src/script/components/FileFullscreenModal/FileFullscreenModal.tsx |
Added edit mode state and conditional rendering of FileEditor vs standard preview |
src/script/components/FileFullscreenModal/FileHeader/FileHeader.tsx |
Added Viewing/Editing toggle buttons and updated download logic to fetch fresh presigned URLs |
src/script/components/FileFullscreenModal/FileHeader/FileHeader.styles.ts |
Styles for edit mode toggle button group |
src/script/util/FileTypeUtil.ts |
New isFileEditable() function checking if file extension supports editing |
src/script/util/FileTypeUtil.test.ts |
Comprehensive tests for isFileEditable() covering various edge cases |
src/script/util/getFileTypeFromExtension/getFileTypeFromExtension.ts |
Added 'odf' to document extensions list |
src/script/repositories/cells/CellsRepository.ts |
Extended getNode() to accept flags and options parameters |
src/script/components/MessagesList/Message/ContentMessage/asset/MultipartAssets/FileAssetCard/common/FileAssetOptions/FileAssetOptions.tsx |
Added "Edit" option to file dropdown menu for editable files |
src/script/components/MessagesList/Message/ContentMessage/asset/MultipartAssets/FileAssetCard/common/FileAssetOptions/FileAssetOptions.test.tsx |
Tests verifying edit option behavior with different file types |
src/script/components/MessagesList/Message/ContentMessage/asset/MultipartAssets/FileAssetCard/common/FilePreviewModal/FilePreviewModal.tsx |
Pass through isEditMode prop |
src/script/components/MessagesList/Message/ContentMessage/asset/MultipartAssets/FileAssetCard/FileAssetWithPreview/FileAssetWithPreview.tsx |
Track edit mode state and handle modal open/close with edit flag |
src/script/components/MessagesList/Message/ContentMessage/asset/MultipartAssets/FileAssetCard/FileAssetSmall/FileAssetSmall.tsx |
Track edit mode state and pass through asset ID |
src/script/components/MessagesList/Message/ContentMessage/asset/MultipartAssets/FileAssetCard/FileAssetCard.tsx |
Pass through asset ID prop |
src/script/components/MessagesList/Message/ContentMessage/asset/MultipartAssets/MultipartAssets.tsx |
Pass asset UUID as ID to child components |
src/script/components/MessagesList/Message/ContentMessage/asset/MultipartAssets/ImageAssetCard/* |
Replace useId() with prop-based ID (UUID) |
src/script/components/MessagesList/Message/ContentMessage/asset/MultipartAssets/VideoAssetCard/* |
Replace useId() with prop-based ID (UUID) |
src/script/components/Conversation/ConversationCells/CellsTable/common/CellsFilePreviewModalContext/CellsFilePreviewModalContext.tsx |
Add isEditMode state and parameter to handleOpenFile |
src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableRowOptions/CellsTableRowOptions.tsx |
Add "Edit" option for editable files in table row dropdown |
src/script/components/Conversation/ConversationCells/CellsTable/CellsFilePreviewModal/CellsFilePreviewModal.tsx |
Use selectedFile.id instead of context ID, pass isEditMode |
src/script/components/CellsGlobalView/CellsTable/common/CellsFilePreviewModalContext/CellsFilePreviewModalContext.tsx |
Add isEditMode state and parameter to handleOpenFile |
src/script/components/CellsGlobalView/CellsTable/CellsTableColumns/CellsTableRowOptions/CellsTableRowOptions.tsx |
Add "Edit" option for editable files in table row dropdown |
src/script/components/CellsGlobalView/CellsTable/CellsFilePreviewModal/CellsFilePreviewModal.tsx |
Use selectedFile.id instead of context ID, pass isEditMode |
src/i18n/en-US.json |
Added translation keys for edit option and editor error messages |
src/types/i18n.d.ts |
Type definitions for new i18n keys |
| if (fileUrl) { | ||
| const node = await cellsRepository.getNode({uuid: id, flags: []}); | ||
| await forcedDownloadFile({url: node.PreSignedGET?.Url || fileUrl, name: fileNameWithExtension}); |
Copilot
AI
Nov 28, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Important] The handleFileDownload function lacks error handling. If the API call or download fails, the user will get no feedback.
Add try-catch with user-facing error handling:
const handleFileDownload = async () => {
if (!fileUrl) return;
try {
const node = await cellsRepository.getNode({uuid: id, flags: []});
await forcedDownloadFile({url: node.PreSignedGET?.Url || fileUrl, name: fileNameWithExtension});
} catch (error) {
// Show user-facing error notification
console.error('Failed to download file:', error);
// Consider adding a toast notification here
}
};| if (fileUrl) { | |
| const node = await cellsRepository.getNode({uuid: id, flags: []}); | |
| await forcedDownloadFile({url: node.PreSignedGET?.Url || fileUrl, name: fileNameWithExtension}); | |
| if (!fileUrl) return; | |
| try { | |
| const node = await cellsRepository.getNode({uuid: id, flags: []}); | |
| await forcedDownloadFile({url: node.PreSignedGET?.Url || fileUrl, name: fileNameWithExtension}); | |
| } catch (error) { | |
| // Show user-facing error notification | |
| console.error('Failed to download file:', error); | |
| // If a toast notification system is available, use it: | |
| // toast.error('Failed to download file. Please try again.'); | |
| // Otherwise, fallback to alert: | |
| alert('Failed to download file. Please try again.'); |
| export const FileEditor = ({id}: FileEditorProps) => { | ||
| const cellsRepository = container.resolve(CellsRepository); | ||
| const [node, setNode] = useState<Node | null>(null); | ||
| const [isLoading, setIsLoading] = useState(true); | ||
| const [isError, setIsError] = useState(false); | ||
|
|
||
| const fetchNode = useCallback(async () => { | ||
| try { | ||
| setIsLoading(true); | ||
| setIsError(false); | ||
| const fetchedNode = await cellsRepository.getNode({uuid: id, flags: ['WithEditorURLs']}); | ||
| setNode(fetchedNode); | ||
| } catch (err) { | ||
| setIsError(true); | ||
| } finally { | ||
| setIsLoading(false); | ||
| } | ||
| }, [id, cellsRepository]); | ||
|
|
||
| // Initial fetch | ||
| useEffect(() => { | ||
| void fetchNode(); | ||
| }, [id, cellsRepository, fetchNode]); | ||
|
|
||
| // Auto-refresh mechanism before expiry | ||
| useEffect(() => { | ||
| if (!node?.EditorURLs?.collabora.ExpiresAt) { | ||
| return; | ||
| } | ||
|
|
||
| const expiresInSeconds = Number(node.EditorURLs.collabora.ExpiresAt); | ||
| const refreshInSeconds = expiresInSeconds - REFRESH_BUFFER_SECONDS; | ||
|
|
||
| // Set timeout to refresh before expiry | ||
| const timeoutId = setTimeout(() => { | ||
| void fetchNode(); | ||
| }, refreshInSeconds * MILLISECONDS_IN_SECOND); | ||
|
|
||
| return () => { | ||
| clearTimeout(timeoutId); | ||
| }; | ||
| }, [node, fetchNode]); | ||
|
|
||
| if (isLoading) { | ||
| return <FileLoader />; | ||
| } | ||
|
|
||
| if (isError || !node) { | ||
| return <div>{t('fileFullscreenModal.editor.error')}</div>; | ||
| } | ||
|
|
||
| return ( | ||
| <iframe | ||
| css={styles.editorIframe} | ||
| src={node.EditorURLs?.collabora.Url} | ||
| title={t('fileFullscreenModal.editor.iframeTitle')} | ||
| /> | ||
| ); | ||
| }; |
Copilot
AI
Nov 28, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Important] The new FileEditor component lacks test coverage. Given its security-sensitive nature (rendering iframes with external URLs), it should have comprehensive tests covering:
- URL validation scenarios
- Error states
- Loading states
- Timeout refresh logic
- Edge cases with expiry times
Consider adding tests in a new FileEditor.test.tsx file.
| // Set timeout to refresh before expiry | ||
| const timeoutId = setTimeout(() => { | ||
| void fetchNode(); | ||
| }, refreshInSeconds * MILLISECONDS_IN_SECOND); | ||
|
|
||
| return () => { | ||
| clearTimeout(timeoutId); | ||
| }; |
Copilot
AI
Nov 28, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Blocker] The timeout calculation can create negative or zero timeouts when expiresInSeconds is less than or equal to REFRESH_BUFFER_SECONDS (10 seconds). This will cause immediate re-fetching in a loop.
Add validation:
const expiresInSeconds = Number(node.EditorURLs.collabora.ExpiresAt);
const refreshInSeconds = Math.max(0, expiresInSeconds - REFRESH_BUFFER_SECONDS);
// Only set timeout if there's meaningful time left
if (refreshInSeconds > 0) {
const timeoutId = setTimeout(() => {
void fetchNode();
}, refreshInSeconds * MILLISECONDS_IN_SECOND);
return () => clearTimeout(timeoutId);
}| // Set timeout to refresh before expiry | |
| const timeoutId = setTimeout(() => { | |
| void fetchNode(); | |
| }, refreshInSeconds * MILLISECONDS_IN_SECOND); | |
| return () => { | |
| clearTimeout(timeoutId); | |
| }; | |
| // Only set timeout if there's meaningful time left | |
| if (refreshInSeconds > 0) { | |
| const timeoutId = setTimeout(() => { | |
| void fetchNode(); | |
| }, refreshInSeconds * MILLISECONDS_IN_SECOND); | |
| return () => { | |
| clearTimeout(timeoutId); | |
| }; | |
| } |
| <iframe | ||
| css={styles.editorIframe} | ||
| src={node.EditorURLs?.collabora.Url} | ||
| title={t('fileFullscreenModal.editor.iframeTitle')} | ||
| /> |
Copilot
AI
Nov 28, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Important] The iframe lacks security attributes recommended for embedding external content. Add sandbox and other security attributes:
<iframe
css={styles.editorIframe}
src={node.EditorURLs?.collabora.Url}
title={t('fileFullscreenModal.editor.iframeTitle')}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
referrerPolicy="no-referrer"
/>Note: Adjust the sandbox permissions based on actual Collabora requirements - use the most restrictive set that still allows the editor to function.
| <button | ||
| title="Viewing" | ||
| aria-label="Viewing" | ||
| className={!isInEditMode ? 'active' : ''} | ||
| onClick={() => onEditModeChange(false)} | ||
| > | ||
| <ShowIcon width={16} height={16} /> | ||
| Viewing | ||
| </button> | ||
| <button | ||
| title="Editing" | ||
| aria-label="Editing" | ||
| className={isInEditMode ? 'active' : ''} | ||
| onClick={() => onEditModeChange(true)} | ||
| > | ||
| <EditIcon width={14} height={14} /> | ||
| Editing | ||
| </button> | ||
| </div> |
Copilot
AI
Nov 28, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Important] The toggle buttons lack proper accessibility attributes. They should include role="button", type="button", and use aria-pressed instead of relying only on CSS classes for state indication.
<button
type="button"
role="button"
title="Viewing"
aria-label="Viewing"
aria-pressed={!isInEditMode}
className={!isInEditMode ? 'active' : ''}
onClick={() => onEditModeChange(false)}
>
<ShowIcon width={16} height={16} />
Viewing
</button>Apply the same pattern to the "Editing" button with aria-pressed={isInEditMode}.
| const refreshInSeconds = expiresInSeconds - REFRESH_BUFFER_SECONDS; | ||
|
|
||
| // Set timeout to refresh before expiry | ||
| const timeoutId = setTimeout(() => { | ||
| void fetchNode(); | ||
| }, refreshInSeconds * MILLISECONDS_IN_SECOND); | ||
|
|
||
| return () => { | ||
| clearTimeout(timeoutId); | ||
| }; |
Copilot
AI
Nov 28, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Bug] Using Number() on ExpiresAt can produce NaN if the value is not a valid number. This would make refreshInSeconds also NaN, leading to unexpected behavior. Add validation:
const expiresInSeconds = Number(node.EditorURLs.collabora.ExpiresAt);
if (isNaN(expiresInSeconds) || expiresInSeconds <= 0) {
return;
}
const refreshInSeconds = Math.max(0, expiresInSeconds - REFRESH_BUFFER_SECONDS);
if (refreshInSeconds > 0) {
const timeoutId = setTimeout(() => {
void fetchNode();
}, refreshInSeconds * MILLISECONDS_IN_SECOND);
return () => clearTimeout(timeoutId);
}| const refreshInSeconds = expiresInSeconds - REFRESH_BUFFER_SECONDS; | |
| // Set timeout to refresh before expiry | |
| const timeoutId = setTimeout(() => { | |
| void fetchNode(); | |
| }, refreshInSeconds * MILLISECONDS_IN_SECOND); | |
| return () => { | |
| clearTimeout(timeoutId); | |
| }; | |
| if (isNaN(expiresInSeconds) || expiresInSeconds <= 0) { | |
| return; | |
| } | |
| const refreshInSeconds = Math.max(0, expiresInSeconds - REFRESH_BUFFER_SECONDS); | |
| // Set timeout to refresh before expiry | |
| if (refreshInSeconds > 0) { | |
| const timeoutId = setTimeout(() => { | |
| void fetchNode(); | |
| }, refreshInSeconds * MILLISECONDS_IN_SECOND); | |
| return () => { | |
| clearTimeout(timeoutId); | |
| }; | |
| } |
| if (fileUrl) { | ||
| const node = await cellsRepository.getNode({uuid: id, flags: []}); | ||
| await forcedDownloadFile({url: node.PreSignedGET?.Url || fileUrl, name: fileNameWithExtension}); |
Copilot
AI
Nov 28, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Important] The download function fetches fresh node data but doesn't validate that PreSignedGET?.Url is present before falling back to fileUrl. If both are undefined or invalid URLs, the download may fail silently. Add validation:
const handleFileDownload = async () => {
if (!fileUrl) return;
try {
const node = await cellsRepository.getNode({uuid: id, flags: []});
const downloadUrl = node.PreSignedGET?.Url || fileUrl;
if (!downloadUrl) {
console.error('No valid download URL available');
return;
}
await forcedDownloadFile({url: downloadUrl, name: fileNameWithExtension});
} catch (error) {
console.error('Failed to download file:', error);
}
};| if (fileUrl) { | |
| const node = await cellsRepository.getNode({uuid: id, flags: []}); | |
| await forcedDownloadFile({url: node.PreSignedGET?.Url || fileUrl, name: fileNameWithExtension}); | |
| if (!fileUrl) { | |
| // No fileUrl provided, cannot proceed | |
| console.error('No fileUrl provided for download'); | |
| return; | |
| } | |
| try { | |
| const node = await cellsRepository.getNode({uuid: id, flags: []}); | |
| const downloadUrl = node.PreSignedGET?.Url || fileUrl; | |
| if (!downloadUrl || typeof downloadUrl !== 'string' || downloadUrl.trim() === '') { | |
| console.error('No valid download URL available'); | |
| return; | |
| } | |
| await forcedDownloadFile({url: downloadUrl, name: fileNameWithExtension}); | |
| } catch (error) { | |
| console.error('Failed to download file:', error); |
|
|
||
| const EDITABLE_FILE_EXTENSIONS = ['odf', 'docx', 'xlsx', 'pptx']; | ||
|
|
Copilot
AI
Nov 28, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] [Suggestion] The isFileEditable function and EDITABLE_FILE_EXTENSIONS constant lack JSDoc documentation explaining what makes a file editable and the business logic behind the supported extensions:
/**
* File extensions that support collaborative editing via Collabora integration.
* Includes modern Office formats (docx, xlsx, pptx) and OpenDocument formats (odf).
*/
const EDITABLE_FILE_EXTENSIONS = ['odf', 'docx', 'xlsx', 'pptx'];
/**
* Checks if a file extension is supported for collaborative editing.
*
* @param fileExtension - The file extension to check (without the leading dot)
* @returns true if the file can be edited with Collabora, false otherwise
*
* @example
* isFileEditable('docx') // returns true
* isFileEditable('pdf') // returns false
*/
export const isFileEditable = (fileExtension: string): boolean => {
return EDITABLE_FILE_EXTENSIONS.includes(fileExtension.toLowerCase());
};| const EDITABLE_FILE_EXTENSIONS = ['odf', 'docx', 'xlsx', 'pptx']; | |
| /** | |
| * List of file extensions that are supported for collaborative editing via Collabora integration. | |
| * | |
| * Business logic: | |
| * - Only modern Office formats (docx, xlsx, pptx) and OpenDocument format (odf) are supported. | |
| * - These formats can be edited collaboratively in the application. | |
| * - Other formats (e.g., pdf, txt) are not supported for editing. | |
| */ | |
| const EDITABLE_FILE_EXTENSIONS = ['odf', 'docx', 'xlsx', 'pptx']; | |
| /** | |
| * Checks if a file extension is supported for collaborative editing. | |
| * | |
| * @param fileExtension - The file extension to check (without the leading dot). | |
| * @returns {boolean} True if the file can be edited with Collabora, false otherwise. | |
| * | |
| * @example | |
| * isFileEditable('docx') // returns true | |
| * isFileEditable('pdf') // returns false | |
| */ |
| <iframe | ||
| css={styles.editorIframe} | ||
| src={node.EditorURLs?.collabora.Url} | ||
| title={t('fileFullscreenModal.editor.iframeTitle')} | ||
| /> |
Copilot
AI
Nov 28, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Important] The iframe src attribute uses an unsanitized URL from the API response (node.EditorURLs?.collabora.Url). While this URL comes from a trusted backend, it should still be validated to ensure it uses HTTPS protocol and doesn't contain javascript: or data: schemes.
Consider adding URL validation:
const validateEditorUrl = (url: string): boolean => {
try {
const parsed = new URL(url);
return parsed.protocol === 'https:';
} catch {
return false;
}
};
// Then in the render:
if (!node.EditorURLs?.collabora.Url || !validateEditorUrl(node.EditorURLs.collabora.Url)) {
return <div>{t('fileFullscreenModal.editor.error')}</div>;
}| const [isEditableState, setIsEditableState] = useState(isEditMode); | ||
| const isEditable = isFileEditable(fileExtension); | ||
|
|
||
| const onCloseModal = () => { | ||
| setIsEditableState(false); | ||
| onClose(); | ||
| }; | ||
|
|
||
| useEffect(() => { | ||
| setIsEditableState(!!isEditMode); | ||
| }, [isEditMode]); |
Copilot
AI
Nov 28, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] [Suggestion] The variable name isEditableState is somewhat redundant. Since it's already a local state variable, consider renaming to isInEditMode to match the prop name used in FileHeader (isInEditMode), improving consistency:
const [isInEditMode, setIsInEditMode] = useState(isEditMode);
const isEditable = isFileEditable(fileExtension);
const onCloseModal = () => {
setIsInEditMode(false);
onClose();
};
useEffect(() => {
setIsInEditMode(!!isEditMode);
}, [isEditMode]);|
🔗 Download Full Report Artifact 🧪 Playwright Test Summary
Failed Tests:❌ Account Management (tags: TC-8639, crit-flow-web)Location: specs/CriticalFlow/accountManagement-TC-8639.spec.ts:37 Errors: ❌ Team owner adds whole team to an all team chat (tags: TC-8631, crit-flow-web)Location: specs/CriticalFlow/addMembersToChat-TC-8631.spec.ts:43 Errors: ❌ Planning group call with sending various messages during call (tags: TC-8632, crit-flow-web)Location: specs/CriticalFlow/groupCalls-TC-8632.spec.ts:37 Errors: ❌ Group Video call (tags: TC-8637, crit-flow-web)Location: specs/CriticalFlow/groupVideoCall-TC-8637.spec.ts:39 Errors: ❌ New person joins team and setups up device (tags: TC-8635, crit-flow-web)Location: specs/CriticalFlow/joinTeam-TC-8635.spec.ts:38 Errors: ❌ Messages in 1:1 (tags: TC-8750, crit-flow-web)Location: specs/CriticalFlow/messagesIn1On1-TC-8750.spec.ts:47 Errors: |


Pull Request
Summary
We have to provide users a feature where they can collaborate on files. Like real time editing as a team.
With Collabora integration, we can allow user to achieve it.
Security Checklist (required)
Standards Acknowledgement (required)
Screenshots or demo (if the user interface changed)
Notes for reviewers
Screen.Recording.2025-11-28.at.16.07.26.mov
We are using
<iframe>to render the URL as we don't have native integration for Collabora.