mattermost-mobile/app/actions/local/draft.ts
Harshil Sharma 04de7b9745
Bor create post (#9393)
* Initial setup for new keyboard

* fix the offset calculation onMove by adding isKeyboardFullyOpened

* Done with the keyboard handling implementation

* Handled keyboard focus and blured state using context

* Added default height for input container

* Android support

* Tablet state handling

* Fix for refreshing offset in list

* Created a default context for mention post list

* Fix linter errors

* Fix tests

* Minor

* Fix the height issue for tablet view

* Review comments

* Dependency fix

* Reveiw comment

* keyboard animation only enabled with screen on top navigation

* added tests

* Added extra keyboard component for emoji picker (#9328)

* Added extra keyboard component

* handled swipe geature for extra keyboard

* scroll to bottom visible on emoji picker

* Check for stale event for keyboard geature area

* fix keyboard stale event for mid-gesture change swipe direction

* Closing emoji picker when message priority is opened

* changing to thread view closes emoji picker

* Close emoij picker and keyboard when attachment icons are clicked

* handle android emoji picker behaviour

* Remove emoji picker code

* fix tests

* Address reviev comments

* Test fixes

* usePreventDoubleTab for race condition and corrected comment on isInputAccessoryViewMode flag

* File attachments option in bottom sheet quick action (#9331)

* Added extra keyboard component

* handled swipe geature for extra keyboard

* File attachments option in bottom sheet quick action

* Updated tests

* i18n

* fix test

* Added tests

* Review comment

* fix tests

* Integrated Emoji picker to Extra keyboard component.  (#9339)

* Added extra keyboard component

* handled swipe geature for extra keyboard

* scroll to bottom visible on emoji picker

* Fix the post input container height change issue

* Added emoji picker with search functionality

* keyboard height not recorded fallback to default height, set search to false closing emoji picker

* fix search funcitonality in android

* scroll to end bottom dismissed with pressed

* Fixed the scroll issue for android

* fix the flickering post input issue

* Wired up the emoji picker

* Added keyboard and spaceback in emoji picker

* intl extract

* initial cursor position and simple calculation review comment

* separated grapheme to utils file

* Review comments

* nitpick

* Fix the bottom safe area and navigation stack restore fix

* Fix test

* disabled emoji picker in edit post screen

* change the name of the variable to name it clarier

* fix the tab gap issue in andriod

* disabled the emoji skin tone change on andriod

* fix the input container jump issue

* fix the failing test

* UX review

* added white space after the attachment icon

* When @ or / is press open keyboard on andriod also fix the scroll issue when keyboard is open

* back bottom closes emoji picker and opening keyboard jump fix

* reverted code

* fixed the flickering issue of input and scroll position fix

* Added BoR button

* Fix the gap issue in thread

* fix the warning reaminated issue when opening a channel

* Fix the extra space issue between input and emoij picker

* Minor fix for extra space between input and emoji picker

* WIP

* Fix thread view bottom safe area and gap between keyboard and searched emojis

* WIP

* refactor: make borConfig optional in BoRAction component

* refactor: remove optional borConfig and simplify state initialization

* Fix the keyboard issue in tablet

* Fix the warning issue react-native-keyboard-controller

* Fix tests

* Fix the tablet search hight issue

* Replaced the ExtraKeyboardProvider with KeyboardProvider from rnkc for permalink

* WIP

* Successfully toggeled bor status

* Displayed bor chip in draft editor

* Send bor post

* Displayed bor chip in draft editor with wrapping

* Added read receipt

* Bottom sheet jump issue resolved

* Search do not close after selecting emoji in search list

* Added the bottom inset height in search emoji for android

* integrated bor receipt count

* fix buid issue

* Added margin

* Handled updating receipts on sync

* test: add comprehensive tests for updateDraftBoRConfig

* test: add initial test file for burn on read label component

* test: add tests for BoRLabel component

* test: update BoRLabel tests to use toBeVisible and remove formatTime mock

* test: update BoR label tests to use renderWithIntl

* refactor: remove getBaseProps and specify props inline in BoRLabel tests

* refactor: improve test component formatting for readability

* test: add validation for BoRLabel time display formats

* test: add comprehensive tests for Burst on Read (BoR) functionality

* WIP

* test: add unit tests for BOR quick action component

* test: add comprehensive tests for BoR quick action component

* test: add missing props to QuickActions test setup

* test: add tests for BoR quick action rendering

* test: mock BoRQuickAction component for testing

* test: add comprehensive tests for observeIsBoREnabled function

* test: add initial test file for BOR read receipts screen

* test: add comprehensive tests for BORReadReceipts component

* test: add test cases for BOR read receipts display

* test: add comprehensive tests for humanReadable format in formatTime

* Added tests

* lint fix

* cleanup

* cleanup

* cleanup

* cleanup

* cleanup

* i18n fix

* Fix the cancel state to emoji picker

* Prevented displaying BoR quick action in threads

* Fixed a test

* lint fix

* review fixes

* review fixes

* i18n fix

* fixed tests

* review fixes

* lint fix

* restored podfile

* Fixed WS event handling for receiving bor post receipnt

* Fixed a test

* fixed TS

* Implemented burn post now for sender and receiver (#9401)

* Implemented burn post now for sender and receiver

* test: add tests for burnPostNow function

* test: add test for delete post option

* test: add comprehensive tests for DeletePostOption component

* Added tests

* Lint fix

* minor improvemenmts

* lint fix

* reset unintended changes

* reset unintended changes

* Bor delete when read by all (#9407)

* WIP

* Updated for all read

* cleanup

* test: add comprehensive tests for handleBoRPostAllRevealed

* test: add tests for BOR events in websocket event handler

* feat: add comprehensive tests for shouldUpdateForBoRPost function

* Added tests

* Lint fix

* fixed TS

* fixed padding

* UX fixes

---------

Co-authored-by: Rajat Dabade <a-rajat.dabade@mattermost.com>
Co-authored-by: Rajat Dabade <rajatdabade1997@gmail.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Rahim Rahman <rahim.rahman@mattermost.com>
2026-02-09 17:00:54 +05:30

409 lines
14 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Image} from 'expo-image';
import {DeviceEventEmitter} from 'react-native';
import {Navigation, Screens} from '@constants';
import {PostTypes} from '@constants/post';
import DatabaseManager from '@database/manager';
import {getDraft} from '@queries/servers/drafts';
import {getCurrentChannelId, getCurrentTeamId, setCurrentTeamAndChannelId} from '@queries/servers/system';
import {addChannelToTeamHistory} from '@queries/servers/team';
import {goToScreen, popTo} from '@screens/navigation';
import NavigationStore from '@store/navigation_store';
import {getExtensionFromMime} from '@utils/file';
import {isTablet} from '@utils/helpers';
import {logError} from '@utils/log';
import {removeImageProxyForKey} from '@utils/markdown';
import {urlSafeBase64Encode} from '@utils/security';
import {isParsableUrl} from '@utils/url';
import type {DraftScreenTab} from '@constants/draft';
import type {Model} from '@nozbe/watermelondb';
type goToScreenParams = {
initialTab?: DraftScreenTab;
}
export const switchToGlobalDrafts = async (serverUrl: string, teamId?: string, initialTab?: DraftScreenTab, prepareRecordsOnly = false) => {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const models: Model[] = [];
let teamIdToUse = teamId;
if (!teamId) {
teamIdToUse = await getCurrentTeamId(database);
}
if (!teamIdToUse) {
throw new Error('no team to switch to');
}
const currentChannelId = await getCurrentChannelId(database);
await setCurrentTeamAndChannelId(operator, teamIdToUse, currentChannelId);
const history = await addChannelToTeamHistory(operator, teamIdToUse, Screens.GLOBAL_DRAFTS, true);
models.push(...history);
if (!prepareRecordsOnly) {
await operator.batchRecords(models, 'switchToGlobalDrafts');
}
const params: goToScreenParams = {};
const isDraftAlreadyInNavigationStack = NavigationStore.getScreensInStack().includes(Screens.GLOBAL_DRAFTS);
if (isDraftAlreadyInNavigationStack) {
popTo(Screens.GLOBAL_DRAFTS);
return {models};
}
params.initialTab = initialTab;
const isTabletDevice = isTablet();
if (isTabletDevice) {
DeviceEventEmitter.emit(Navigation.NAVIGATION_HOME, Screens.GLOBAL_DRAFTS, params);
} else {
goToScreen(Screens.GLOBAL_DRAFTS, '', params, {topBar: {visible: false}});
}
return {models};
} catch (error) {
logError('Failed switchToGlobalDrafts', error);
return {error};
}
};
export async function updateDraftFile(serverUrl: string, channelId: string, rootId: string, file: FileInfo, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const draft = await getDraft(database, channelId, rootId);
if (!draft) {
return {error: 'no draft'};
}
const i = draft.files.findIndex((v) => v.clientId === file.clientId);
if (i === -1) {
return {error: 'file not found'};
}
// We create a new list to make sure we re-render the draft input.
const newFiles = [...draft.files];
newFiles[i] = file;
draft.prepareUpdate((d) => {
d.files = newFiles;
d.updateAt = Date.now();
});
if (!prepareRecordsOnly) {
await operator.batchRecords([draft], 'updateDraftFile');
}
return {draft};
} catch (error) {
logError('Failed updateDraftFile', error);
return {error};
}
}
export async function removeDraftFile(serverUrl: string, channelId: string, rootId: string, clientId: string, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const draft = await getDraft(database, channelId, rootId);
if (!draft) {
return {error: 'no draft'};
}
const i = draft.files.findIndex((v) => v.clientId === clientId);
if (i === -1) {
return {error: 'file not found'};
}
if (draft.files.length === 1 && !draft.message) {
draft.prepareDestroyPermanently();
} else {
draft.prepareUpdate((d) => {
d.files = draft.files.filter((v, index) => index !== i);
d.updateAt = Date.now();
});
}
if (!prepareRecordsOnly) {
await operator.batchRecords([draft], 'removeDraftFile');
}
return {draft};
} catch (error) {
logError('Failed removeDraftFile', error);
return {error};
}
}
export async function updateDraftMessage(serverUrl: string, channelId: string, rootId: string, message: string, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const draft = await getDraft(database, channelId, rootId);
if (!draft) {
if (!message) {
return {};
}
const newDraft: Draft = {
channel_id: channelId,
root_id: rootId,
message,
update_at: Date.now(),
};
return operator.handleDraft({drafts: [newDraft], prepareRecordsOnly});
}
if (draft.message === message) {
return {draft};
}
if (draft.files.length === 0 && !message) {
draft.prepareDestroyPermanently();
} else {
draft.prepareUpdate((d) => {
d.message = message;
d.updateAt = Date.now();
});
}
if (!prepareRecordsOnly) {
await operator.batchRecords([draft], 'updateDraftMessage');
}
return {draft};
} catch (error) {
logError('Failed updateDraftMessage', error);
return {error};
}
}
export async function addFilesToDraft(serverUrl: string, channelId: string, rootId: string, files: FileInfo[], prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const draft = await getDraft(database, channelId, rootId);
if (!draft) {
const newDraft: Draft = {
channel_id: channelId,
root_id: rootId,
files,
message: '',
update_at: Date.now(),
};
return operator.handleDraft({drafts: [newDraft], prepareRecordsOnly});
}
draft.prepareUpdate((d) => {
d.files = [...draft.files, ...files];
d.updateAt = Date.now();
});
if (!prepareRecordsOnly) {
await operator.batchRecords([draft], 'addFilesToDraft');
}
return {draft};
} catch (error) {
logError('Failed addFilesToDraft', error);
return {error};
}
}
export const removeDraft = async (serverUrl: string, channelId: string, rootId = '') => {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const draft = await getDraft(database, channelId, rootId);
if (draft) {
await database.write(async () => {
await draft.destroyPermanently();
});
}
return {draft};
} catch (error) {
logError('Failed removeDraft', error);
return {error};
}
};
export async function updateDraftPriority(serverUrl: string, channelId: string, rootId: string, postPriority: PostPriority, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const draft = await getDraft(database, channelId, rootId);
if (!draft) {
const newDraft: Draft = {
channel_id: channelId,
root_id: rootId,
metadata: {
priority: postPriority,
},
update_at: Date.now(),
};
return operator.handleDraft({drafts: [newDraft], prepareRecordsOnly});
}
draft.prepareUpdate((d) => {
d.metadata = {
...d.metadata,
priority: postPriority,
};
d.updateAt = Date.now();
});
if (!prepareRecordsOnly) {
await operator.batchRecords([draft], 'updateDraftPriority');
}
return {draft};
} catch (error) {
logError('Failed updateDraftPriority', error);
return {error};
}
}
export async function updateDraftBoRConfig(serverUrl: string, channelId: string, rootId: string, postBoRConfig: PostBoRConfig, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const draft = await getDraft(database, channelId, rootId);
if (!draft) {
const newDraft: Draft = {
channel_id: channelId,
root_id: rootId,
update_at: Date.now(),
metadata: {
borConfig: postBoRConfig,
},
};
if (postBoRConfig.enabled) {
newDraft.type = PostTypes.BURN_ON_READ;
} else {
newDraft.type = '';
}
return operator.handleDraft({drafts: [newDraft], prepareRecordsOnly});
}
draft?.prepareUpdate((d) => {
d.metadata = {
...d.metadata,
borConfig: postBoRConfig,
};
if (postBoRConfig.enabled) {
d.type = PostTypes.BURN_ON_READ;
} else {
d.type = '';
}
});
if (!prepareRecordsOnly) {
await operator.batchRecords([draft], 'updateDraftBoRConfig');
}
return {draft};
} catch (error) {
logError('Failed updateDraftBoRConfig', error);
return {error};
}
}
export async function updateDraftMarkdownImageMetadata({
serverUrl,
channelId,
rootId,
imageMetadata,
prepareRecordsOnly = false,
}: {
serverUrl: string;
channelId: string;
rootId: string;
imageMetadata: Dictionary<PostImage | undefined>;
prepareRecordsOnly?: boolean;
}) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const draft = await getDraft(database, channelId, rootId);
if (draft) {
draft.prepareUpdate((d) => {
d.metadata = {
...d.metadata,
images: imageMetadata,
};
d.updateAt = Date.now();
});
if (!prepareRecordsOnly) {
await operator.batchRecords([draft], 'updateDraftImageMetadata');
}
}
return {draft};
} catch (error) {
logError('Failed updateDraftMarkdownImageMetadata', error);
return {error};
}
}
async function getImageMetadata(serverUrl: string, url: string) {
let format;
const sourceKey = removeImageProxyForKey(url);
const cacheKey = `uid-${urlSafeBase64Encode(sourceKey)}`;
const image = await Image.loadAsync({uri: url, cacheKey, cachePath: urlSafeBase64Encode(serverUrl)});
if (image.mediaType) {
format = getExtensionFromMime(image.mediaType);
} else {
/**
* Regex Explanation:
* \. - Matches a literal period (e.g., before "jpg").
* (\w+) - Captures the file extension (letters, digits, or underscores).
* (?=\?|$) - Ensures the extension is followed by "?" or the end of the URL.
*
* * Example Matches:
* "https://example.com/image.jpg" -> Matches "jpg"
* "https://example.com/image.png?size=1" -> Matches "png"
* "https://example.com/file" -> No match (no file extension).
*/
const match = url.match(/\.(\w+)(?=\?|$)/);
if (match) {
format = match[1];
}
}
return {
height: image.height,
width: image.width,
format,
frame_count: 1,
url,
};
}
export async function parseMarkdownImages(serverUrl: string, markdown: string, imageMetadata: Dictionary<PostImage | undefined>) {
// Regex break down
// ([a-zA-Z][a-zA-Z\d+\-.]*):\/\/ - Matches any valid scheme (protocol), such as http, https, ftp, mailto, file, etc.
// [^\s()<>]+ - Matches the main part of the URL, excluding spaces, parentheses, and angle brackets.
// (?:\([^\s()<>]+\))* - Allows balanced parentheses inside the URL path or query parameters.
// !\[.*?\]\((...)\) - Matches an image markdown syntax ![alt text](image url)
const imageRegex = /!\[.*?\]\((([a-zA-Z][a-zA-Z\d+\-.]*):\/\/[^\s()<>]+(?:\([^\s()<>]+\))*)\)/g;
const matches = Array.from(markdown.matchAll(imageRegex));
const promises = matches.reduce<Array<Promise<PostImage & {url: string}>>>((result, match) => {
const imageUrl = match[1];
if (isParsableUrl(imageUrl)) {
result.push(getImageMetadata(serverUrl, imageUrl));
}
return result;
}, []);
const metadataArray = await Promise.all(promises);
metadataArray.forEach((metadata) => {
if (metadata) {
imageMetadata[metadata.url] = metadata;
}
});
}