Merge branch 'develop' into chore.upgrade-rn-0.73.6
|
@ -19,13 +19,13 @@ module.exports = {
|
|||
type: 'ios.app',
|
||||
binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/Rocket.Chat Experimental.app',
|
||||
build:
|
||||
'xcodebuild -workspace ios/RocketChatRN.xcworkspace -scheme RocketChatRN -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build'
|
||||
'xcodebuild -workspace ios/RocketChatRN.xcworkspace -scheme RocketChatRN -configuration Debug -destination \'generic/platform=iphonesimulator\' -derivedDataPath ios/build'
|
||||
},
|
||||
'ios.release': {
|
||||
type: 'ios.app',
|
||||
binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/Rocket.Chat Experimental.app',
|
||||
build:
|
||||
'xcodebuild -workspace ios/RocketChatRN.xcworkspace -scheme RocketChatRN -configuration Release -sdk iphonesimulator -derivedDataPath ios/build'
|
||||
'xcodebuild -workspace ios/RocketChatRN.xcworkspace -scheme RocketChatRN -configuration Release -destination \'generic/platform=iphonesimulator\' -derivedDataPath ios/build'
|
||||
},
|
||||
'android.debug': {
|
||||
type: 'android.apk',
|
||||
|
|
|
@ -93,7 +93,7 @@ android {
|
|||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode VERSIONCODE as Integer
|
||||
versionName "4.48.0"
|
||||
versionName "4.49.0"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
if (!isFoss) {
|
||||
manifestPlaceholders = [BugsnagAPIKey: BugsnagAPIKey as String]
|
||||
|
|
|
@ -34,8 +34,6 @@ import android.security.KeyChainAliasCallback;
|
|||
import java.util.Arrays;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import com.RNFetchBlob.RNFetchBlob;
|
||||
|
||||
import com.reactnativecommunity.webview.RNCWebViewManager;
|
||||
|
||||
import com.dylanvann.fastimage.FastImageOkHttpUrlLoader;
|
||||
|
@ -109,8 +107,6 @@ public class SSLPinningModule extends ReactContextBaseJavaModule implements KeyC
|
|||
.newBuilder(this.reactContext, getOkHttpClient())
|
||||
.build();
|
||||
Fresco.initialize(this.reactContext, config);
|
||||
// RNFetchBlob networking layer
|
||||
RNFetchBlob.applyCustomOkHttpClient(getOkHttpClient());
|
||||
// RNCWebView onReceivedClientCertRequest
|
||||
RNCWebViewManager.setCertificateAlias(data);
|
||||
// FastImage Glide network layer
|
||||
|
|
|
@ -95,21 +95,16 @@ export const RecordAudio = (): ReactElement | null => {
|
|||
try {
|
||||
if (!rid) return;
|
||||
setRecordingAudio(false);
|
||||
const fileURI = recordingRef.current?.getURI() as string;
|
||||
const fileData = await getInfoAsync(fileURI);
|
||||
|
||||
if (!fileData.exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileInfo: IUpload = {
|
||||
rid,
|
||||
const fileURI = recordingRef.current?.getURI();
|
||||
const fileData = await getInfoAsync(fileURI as string);
|
||||
const fileInfo = {
|
||||
name: `${Date.now()}${RECORDING_EXTENSION}`,
|
||||
mime: 'audio/aac',
|
||||
type: 'audio/aac',
|
||||
store: 'Uploads',
|
||||
path: fileURI,
|
||||
size: fileData.size
|
||||
};
|
||||
size: fileData.exists ? fileData.size : null
|
||||
} as IUpload;
|
||||
|
||||
if (fileInfo) {
|
||||
if (permissionToUpload) {
|
||||
|
|
|
@ -1,24 +1,28 @@
|
|||
import { useRoute } from '@react-navigation/native';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { saveDraftMessage } from '../../../lib/methods/draftMessage';
|
||||
import { useRoomContext } from '../../../views/RoomView/context';
|
||||
import { useFocused } from '../context';
|
||||
import { saveDraftMessage } from '../../../lib/methods/draftMessage';
|
||||
|
||||
export const useAutoSaveDraft = (text = '') => {
|
||||
const route = useRoute();
|
||||
const { rid, tmid, action, selectedMessages } = useRoomContext();
|
||||
const focused = useFocused();
|
||||
const oldText = useRef('');
|
||||
const intervalRef = useRef();
|
||||
|
||||
const saveMessageDraft = useCallback(() => {
|
||||
if (action === 'edit') return;
|
||||
const mounted = useRef(true);
|
||||
|
||||
const saveMessageDraft = useCallback(() => {
|
||||
if (route.name === 'ShareView') return;
|
||||
if (action === 'edit') return;
|
||||
const draftMessage = selectedMessages?.length ? JSON.stringify({ quotes: selectedMessages, msg: text }) : text;
|
||||
if (oldText.current !== draftMessage) {
|
||||
if (oldText.current !== draftMessage || (oldText.current === '' && draftMessage === '')) {
|
||||
oldText.current = draftMessage;
|
||||
saveDraftMessage({ rid, tmid, draftMessage });
|
||||
}
|
||||
}, [action, rid, tmid, text, selectedMessages?.length]);
|
||||
}, [action, rid, tmid, text, selectedMessages?.length, route.name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focused) {
|
||||
|
@ -29,7 +33,23 @@ export const useAutoSaveDraft = (text = '') => {
|
|||
|
||||
return () => {
|
||||
clearInterval(intervalRef.current);
|
||||
saveMessageDraft();
|
||||
};
|
||||
}, [focused, saveMessageDraft]);
|
||||
|
||||
// hack to call saveMessageDraft when component is unmounted
|
||||
useEffect(() => {
|
||||
() => {};
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (!mounted.current) {
|
||||
saveMessageDraft();
|
||||
}
|
||||
},
|
||||
[saveMessageDraft]
|
||||
);
|
||||
};
|
||||
|
|
|
@ -136,7 +136,7 @@ export const useChooseMedia = ({
|
|||
// FIXME: use useNavigation
|
||||
Navigation.navigate('ShareView', {
|
||||
room,
|
||||
thread,
|
||||
thread: thread || tmid,
|
||||
attachments,
|
||||
action,
|
||||
finishShareView,
|
||||
|
|
|
@ -13,8 +13,9 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
text: {
|
||||
fontSize: 14,
|
||||
...sharedStyles.textRegular,
|
||||
...sharedStyles.textAlignCenter
|
||||
// jest error: TypeError: Cannot read property 'textRegular' of undefined
|
||||
...sharedStyles?.textRegular,
|
||||
...sharedStyles?.textAlignCenter
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -55,14 +55,14 @@ const AttachedActions = ({ attachment, getCustomEmoji }: { attachment: IAttachme
|
|||
|
||||
const Attachments: React.FC<IMessageAttachments> = React.memo(
|
||||
({ attachments, timeFormat, showAttachment, style, getCustomEmoji, isReply, author }: IMessageAttachments) => {
|
||||
const { translateLanguage, isEncrypted } = useContext(MessageContext);
|
||||
const { translateLanguage } = useContext(MessageContext);
|
||||
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const attachmentsElements = attachments.map((file: IAttachment, index: number) => {
|
||||
const msg = isEncrypted ? '' : getMessageFromAttachment(file, translateLanguage);
|
||||
const msg = getMessageFromAttachment(file, translateLanguage);
|
||||
if (file && file.image_url) {
|
||||
return (
|
||||
<Image
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
import React from 'react';
|
||||
|
||||
import { CustomIcon } from '../../../CustomIcon';
|
||||
import styles from '../../styles';
|
||||
|
||||
const Pinned = ({ pinned, testID }: { pinned?: boolean; testID?: string }): React.ReactElement | null => {
|
||||
if (pinned) return <CustomIcon testID={testID} name='pin' size={16} style={styles.rightIcons} />;
|
||||
return null;
|
||||
};
|
||||
|
||||
export default Pinned;
|
|
@ -1,12 +1,13 @@
|
|||
import React from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
|
||||
import Encrypted from './Encrypted';
|
||||
import { MessageType } from '../../../../definitions';
|
||||
import Edited from './Edited';
|
||||
import Encrypted from './Encrypted';
|
||||
import MessageError from './MessageError';
|
||||
import Pinned from './Pinned';
|
||||
import ReadReceipt from './ReadReceipt';
|
||||
import Translated from './Translated';
|
||||
import { MessageType } from '../../../../definitions';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
actionIcons: {
|
||||
|
@ -22,10 +23,21 @@ interface IRightIcons {
|
|||
unread?: boolean;
|
||||
hasError: boolean;
|
||||
isTranslated: boolean;
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
const RightIcons = ({ type, msg, isEdited, hasError, isReadReceiptEnabled, unread, isTranslated }: IRightIcons) => (
|
||||
const RightIcons = ({
|
||||
type,
|
||||
msg,
|
||||
isEdited,
|
||||
hasError,
|
||||
isReadReceiptEnabled,
|
||||
unread,
|
||||
isTranslated,
|
||||
pinned
|
||||
}: IRightIcons): React.ReactElement => (
|
||||
<View style={styles.actionIcons}>
|
||||
<Pinned pinned={pinned} testID={`${msg}-pinned`} />
|
||||
<Encrypted type={type} />
|
||||
<Edited testID={`${msg}-edited`} isEdited={isEdited} />
|
||||
<MessageError hasError={hasError} />
|
||||
|
|
|
@ -127,6 +127,13 @@ export const Edited = () => (
|
|||
</>
|
||||
);
|
||||
|
||||
export const Pinned = () => (
|
||||
<>
|
||||
<Message msg='Message header' pinned />
|
||||
<Message msg='Message without header' pinned isHeader={false} />
|
||||
</>
|
||||
);
|
||||
|
||||
export const Translated = () => (
|
||||
<>
|
||||
<Message msg='Message header' isTranslated />
|
||||
|
|
|
@ -117,6 +117,7 @@ const Message = React.memo((props: IMessage) => {
|
|||
hasError={props.hasError}
|
||||
isReadReceiptEnabled={props.isReadReceiptEnabled}
|
||||
unread={props.unread}
|
||||
pinned={props.pinned}
|
||||
isTranslated={props.isTranslated}
|
||||
/>
|
||||
) : null}
|
||||
|
|
|
@ -1,21 +1,21 @@
|
|||
import { dequal } from 'dequal';
|
||||
import moment from 'moment';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import moment from 'moment';
|
||||
import { dequal } from 'dequal';
|
||||
import FastImage from 'react-native-fast-image';
|
||||
|
||||
import Touchable from './Touchable';
|
||||
import Markdown from '../markdown';
|
||||
import openLink from '../../lib/methods/helpers/openLink';
|
||||
import sharedStyles from '../../views/Styles';
|
||||
import { themes } from '../../lib/constants';
|
||||
import MessageContext from './Context';
|
||||
import { fileDownloadAndPreview } from './helpers/fileDownload';
|
||||
import { IAttachment, TGetCustomEmoji } from '../../definitions';
|
||||
import RCActivityIndicator from '../ActivityIndicator';
|
||||
import Attachments from './Attachments';
|
||||
import { TSupportedThemes, useTheme } from '../../theme';
|
||||
import { themes } from '../../lib/constants';
|
||||
import { fileDownloadAndPreview } from '../../lib/methods/helpers';
|
||||
import { formatAttachmentUrl } from '../../lib/methods/helpers/formatAttachmentUrl';
|
||||
import openLink from '../../lib/methods/helpers/openLink';
|
||||
import { TSupportedThemes, useTheme } from '../../theme';
|
||||
import sharedStyles from '../../views/Styles';
|
||||
import RCActivityIndicator from '../ActivityIndicator';
|
||||
import Markdown from '../markdown';
|
||||
import Attachments from './Attachments';
|
||||
import MessageContext from './Context';
|
||||
import Touchable from './Touchable';
|
||||
import messageStyles from './styles';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
|
|
|
@ -60,6 +60,7 @@ interface IMessageUser {
|
|||
isEdited: boolean;
|
||||
isReadReceiptEnabled?: boolean;
|
||||
unread?: boolean;
|
||||
pinned?: boolean;
|
||||
isTranslated: boolean;
|
||||
}
|
||||
|
||||
|
@ -124,6 +125,7 @@ const User = React.memo(
|
|||
hasError={hasError}
|
||||
isReadReceiptEnabled={props.isReadReceiptEnabled}
|
||||
unread={props.unread}
|
||||
pinned={props.pinned}
|
||||
isTranslated={isTranslated}
|
||||
/>
|
||||
</View>
|
||||
|
|
|
@ -14,7 +14,7 @@ import {
|
|||
isDownloadActive,
|
||||
resumeMediaFile
|
||||
} from '../../lib/methods/handleMediaDownload';
|
||||
import { isIOS } from '../../lib/methods/helpers';
|
||||
import { fileDownload, isIOS } from '../../lib/methods/helpers';
|
||||
import EventEmitter from '../../lib/methods/helpers/events';
|
||||
import { formatAttachmentUrl } from '../../lib/methods/helpers/formatAttachmentUrl';
|
||||
import { useTheme } from '../../theme';
|
||||
|
@ -24,7 +24,6 @@ import Markdown from '../markdown';
|
|||
import BlurComponent from './Components/OverlayComponent';
|
||||
import MessageContext from './Context';
|
||||
import Touchable from './Touchable';
|
||||
import { fileDownload } from './helpers/fileDownload';
|
||||
import { DEFAULT_MESSAGE_HEIGHT } from './utils';
|
||||
|
||||
const SUPPORTED_TYPES = ['video/quicktime', 'video/mp4', ...(isIOS ? [] : ['video/3gp', 'video/mkv'])];
|
||||
|
|
|
@ -1,53 +0,0 @@
|
|||
import RNFetchBlob, { FetchBlobResponse } from 'rn-fetch-blob';
|
||||
import FileViewer from 'react-native-file-viewer';
|
||||
|
||||
import EventEmitter from '../../../../lib/methods/helpers/events';
|
||||
import { LISTENER } from '../../../Toast';
|
||||
import I18n from '../../../../i18n';
|
||||
import { DOCUMENTS_PATH, DOWNLOAD_PATH } from '../../../../lib/constants';
|
||||
import { IAttachment } from '../../../../definitions';
|
||||
|
||||
export const getLocalFilePathFromFile = (localPath: string, attachment: IAttachment): string => `${localPath}${attachment.title}`;
|
||||
|
||||
export const fileDownload = (url: string, attachment: IAttachment): Promise<FetchBlobResponse> => {
|
||||
const path = getLocalFilePathFromFile(DOWNLOAD_PATH, attachment);
|
||||
|
||||
const options = {
|
||||
path,
|
||||
timeout: 10000,
|
||||
indicator: true,
|
||||
overwrite: true,
|
||||
addAndroidDownloads: {
|
||||
path,
|
||||
notification: true,
|
||||
useDownloadManager: true
|
||||
}
|
||||
};
|
||||
|
||||
return RNFetchBlob.config(options).fetch('GET', url);
|
||||
};
|
||||
|
||||
export const fileDownloadAndPreview = async (url: string, attachment: IAttachment): Promise<void> => {
|
||||
try {
|
||||
const path = getLocalFilePathFromFile(DOCUMENTS_PATH, attachment);
|
||||
const file = await RNFetchBlob.config({
|
||||
timeout: 10000,
|
||||
indicator: true,
|
||||
path
|
||||
}).fetch('GET', url);
|
||||
|
||||
FileViewer.open(file.data, {
|
||||
showOpenWithDialog: true,
|
||||
showAppsSuggestions: true
|
||||
})
|
||||
.then(res => res)
|
||||
.catch(async () => {
|
||||
const file = await fileDownload(url, attachment);
|
||||
file
|
||||
? EventEmitter.emit(LISTENER, { message: I18n.t('Downloaded_file') })
|
||||
: EventEmitter.emit(LISTENER, { message: I18n.t('Error_Download_file') });
|
||||
});
|
||||
} catch (e) {
|
||||
EventEmitter.emit(LISTENER, { message: I18n.t('Error_Download_file') });
|
||||
}
|
||||
};
|
|
@ -390,7 +390,8 @@ class MessageContainer extends React.Component<IMessageContainerProps, IMessageC
|
|||
autoTranslate: autoTranslateMessage,
|
||||
replies,
|
||||
md,
|
||||
comment
|
||||
comment,
|
||||
pinned
|
||||
} = item;
|
||||
|
||||
let message = msg;
|
||||
|
@ -428,8 +429,7 @@ class MessageContainer extends React.Component<IMessageContainerProps, IMessageC
|
|||
threadBadgeColor,
|
||||
toggleFollowThread,
|
||||
replies,
|
||||
translateLanguage: canTranslateMessage ? autoTranslateLanguage : undefined,
|
||||
isEncrypted: this.isEncrypted
|
||||
translateLanguage: canTranslateMessage ? autoTranslateLanguage : undefined
|
||||
}}
|
||||
>
|
||||
{/* @ts-ignore*/}
|
||||
|
@ -486,6 +486,7 @@ class MessageContainer extends React.Component<IMessageContainerProps, IMessageC
|
|||
isTranslated={isTranslated}
|
||||
isBeingEdited={isBeingEdited}
|
||||
isPreview={isPreview}
|
||||
pinned={pinned}
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
);
|
||||
|
|
|
@ -64,6 +64,7 @@ export interface IMessageContent {
|
|||
hasError: boolean;
|
||||
isHeader: boolean;
|
||||
isTranslated: boolean;
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
export interface IMessageEmoji {
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
import Model from '@nozbe/watermelondb/Model';
|
||||
|
||||
import { E2EType, MessageType } from './IMessage';
|
||||
|
||||
export interface IUpload {
|
||||
id?: string;
|
||||
rid: string;
|
||||
rid?: string;
|
||||
path: string;
|
||||
name?: string;
|
||||
tmid?: string;
|
||||
|
@ -16,8 +14,6 @@ export interface IUpload {
|
|||
error?: boolean;
|
||||
subscription?: { id: string };
|
||||
msg?: string;
|
||||
t?: MessageType;
|
||||
e2e?: E2EType;
|
||||
}
|
||||
|
||||
export type TUploadModel = IUpload & Model;
|
||||
|
|
|
@ -6,7 +6,6 @@ export * from './environment';
|
|||
export * from './keys';
|
||||
export * from './links';
|
||||
export * from './localAuthentication';
|
||||
export * from './localPath';
|
||||
export * from './messagesStatus';
|
||||
export * from './messageTypeLoad';
|
||||
export * from './notifications';
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
import RNFetchBlob from 'rn-fetch-blob';
|
||||
|
||||
export const DOCUMENTS_PATH = `${RNFetchBlob.fs.dirs.DocumentDir}/`;
|
||||
export const DOWNLOAD_PATH = `${RNFetchBlob.fs.dirs.DownloadDir}/`;
|
|
@ -11,15 +11,7 @@ import log from '../methods/helpers/log';
|
|||
import { store } from '../store/auxStore';
|
||||
import { joinVectorData, randomPassword, splitVectorData, toString, utf8ToBuffer } from './utils';
|
||||
import { EncryptionRoom } from './index';
|
||||
import {
|
||||
IMessage,
|
||||
ISubscription,
|
||||
IUpload,
|
||||
TMessageModel,
|
||||
TSubscriptionModel,
|
||||
TThreadMessageModel,
|
||||
TThreadModel
|
||||
} from '../../definitions';
|
||||
import { IMessage, ISubscription, TMessageModel, TSubscriptionModel, TThreadMessageModel, TThreadModel } from '../../definitions';
|
||||
import {
|
||||
E2E_BANNER_TYPE,
|
||||
E2E_MESSAGE_TYPE,
|
||||
|
@ -29,7 +21,6 @@ import {
|
|||
E2E_STATUS
|
||||
} from '../constants';
|
||||
import { Services } from '../services';
|
||||
import { compareServerVersion } from '../methods/helpers';
|
||||
|
||||
class Encryption {
|
||||
ready: boolean;
|
||||
|
@ -43,7 +34,6 @@ class Encryption {
|
|||
handshake: Function;
|
||||
decrypt: Function;
|
||||
encrypt: Function;
|
||||
encryptUpload: Function;
|
||||
importRoomKey: Function;
|
||||
};
|
||||
};
|
||||
|
@ -285,7 +275,7 @@ class Encryption {
|
|||
];
|
||||
toDecrypt = (await Promise.all(
|
||||
toDecrypt.map(async message => {
|
||||
const { t, msg, tmsg, attachments } = message;
|
||||
const { t, msg, tmsg } = message;
|
||||
let newMessage: TMessageModel = {} as TMessageModel;
|
||||
if (message.subscription) {
|
||||
const { id: rid } = message.subscription;
|
||||
|
@ -294,8 +284,7 @@ class Encryption {
|
|||
t,
|
||||
rid,
|
||||
msg: msg as string,
|
||||
tmsg,
|
||||
attachments
|
||||
tmsg
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -445,7 +434,7 @@ class Encryption {
|
|||
};
|
||||
|
||||
// Encrypt a message
|
||||
encryptMessage = async (message: IMessage | IUpload) => {
|
||||
encryptMessage = async (message: IMessage) => {
|
||||
const { rid } = message;
|
||||
const db = database.active;
|
||||
const subCollection = db.get('subscriptions');
|
||||
|
@ -467,11 +456,6 @@ class Encryption {
|
|||
}
|
||||
|
||||
const roomE2E = await this.getRoomInstance(rid);
|
||||
|
||||
const { version: serverVersion } = store.getState().server;
|
||||
if ('path' in message && compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '6.8.0')) {
|
||||
return roomE2E.encryptUpload(message);
|
||||
}
|
||||
return roomE2E.encrypt(message);
|
||||
} catch {
|
||||
// Subscription not found
|
||||
|
@ -483,7 +467,7 @@ class Encryption {
|
|||
};
|
||||
|
||||
// Decrypt a message
|
||||
decryptMessage = async (message: Pick<IMessage, 't' | 'e2e' | 'rid' | 'msg' | 'tmsg' | 'attachments'>) => {
|
||||
decryptMessage = async (message: Pick<IMessage, 't' | 'e2e' | 'rid' | 'msg' | 'tmsg'>) => {
|
||||
const { t, e2e } = message;
|
||||
|
||||
// Prevent create a new instance if this room was encrypted sometime ago
|
||||
|
|
|
@ -5,7 +5,7 @@ import ByteBuffer from 'bytebuffer';
|
|||
import parse from 'url-parse';
|
||||
|
||||
import getSingleMessage from '../methods/getSingleMessage';
|
||||
import { IMessage, IUpload, IUser } from '../../definitions';
|
||||
import { IMessage, IUser } from '../../definitions';
|
||||
import Deferred from './helpers/deferred';
|
||||
import { debounce } from '../methods/helpers';
|
||||
import database from '../database';
|
||||
|
@ -243,38 +243,8 @@ export default class EncryptionRoom {
|
|||
return message;
|
||||
};
|
||||
|
||||
// Encrypt upload
|
||||
encryptUpload = async (message: IUpload) => {
|
||||
if (!this.ready) {
|
||||
return message;
|
||||
}
|
||||
|
||||
try {
|
||||
let description = '';
|
||||
|
||||
if (message.description) {
|
||||
description = await this.encryptText(EJSON.stringify({ text: message.description }));
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
t: E2E_MESSAGE_TYPE,
|
||||
e2e: E2E_STATUS.PENDING,
|
||||
description
|
||||
};
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
// Decrypt text
|
||||
decryptText = async (msg: string | ArrayBuffer) => {
|
||||
if (!msg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
msg = b64ToBuffer(msg.slice(12) as string);
|
||||
const [vector, cipherText] = splitVectorData(msg);
|
||||
|
||||
|
@ -305,10 +275,6 @@ export default class EncryptionRoom {
|
|||
tmsg = await this.decryptText(tmsg);
|
||||
}
|
||||
|
||||
if (message.attachments?.length) {
|
||||
message.attachments[0].description = await this.decryptText(message.attachments[0].description as string);
|
||||
}
|
||||
|
||||
const decryptedMessage: IMessage = {
|
||||
...message,
|
||||
tmsg,
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import RNFetchBlob from 'rn-fetch-blob';
|
||||
import { settings as RocketChatSettings } from '@rocket.chat/sdk';
|
||||
import { KJUR } from 'jsrsasign';
|
||||
import moment from 'moment';
|
||||
|
@ -45,12 +44,12 @@ const verifyJWT = (jwt?: string): ISupportedVersionsData | null => {
|
|||
|
||||
export async function getServerInfo(server: string): Promise<TServerInfoResult> {
|
||||
try {
|
||||
const response = await RNFetchBlob.fetch('GET', `${server}/api/info`, {
|
||||
const response = await fetch(`${server}/api/info`, {
|
||||
...RocketChatSettings.customHeaders
|
||||
});
|
||||
try {
|
||||
const jsonRes: IApiServerInfo = response.json();
|
||||
if (!jsonRes?.success) {
|
||||
const serverInfo: IApiServerInfo = await response.json();
|
||||
if (!serverInfo?.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: I18n.t('Not_RC_Server', { contact: I18n.t('Contact_your_server_admin') })
|
||||
|
@ -58,7 +57,7 @@ export async function getServerInfo(server: string): Promise<TServerInfoResult>
|
|||
}
|
||||
|
||||
// Makes use of signed JWT to get supported versions
|
||||
const supportedVersions = verifyJWT(jsonRes.supportedVersions?.signed);
|
||||
const supportedVersions = verifyJWT(serverInfo.supportedVersions?.signed);
|
||||
|
||||
// if backend doesn't have supported versions or JWT is invalid, request from cloud
|
||||
if (!supportedVersions) {
|
||||
|
@ -69,7 +68,7 @@ export async function getServerInfo(server: string): Promise<TServerInfoResult>
|
|||
moment(new Date()).diff(serverRecord?.supportedVersionsUpdatedAt, 'hours') <= SV_CLOUD_UPDATE_INTERVAL
|
||||
) {
|
||||
return {
|
||||
...jsonRes,
|
||||
...serverInfo,
|
||||
success: true
|
||||
};
|
||||
}
|
||||
|
@ -79,7 +78,7 @@ export async function getServerInfo(server: string): Promise<TServerInfoResult>
|
|||
// Allows airgapped servers to use the app until enforcementStartDate
|
||||
if (!cloudInfo) {
|
||||
return {
|
||||
...jsonRes,
|
||||
...serverInfo,
|
||||
success: true
|
||||
};
|
||||
}
|
||||
|
@ -88,14 +87,14 @@ export async function getServerInfo(server: string): Promise<TServerInfoResult>
|
|||
const supportedVersionsCloud = verifyJWT(cloudInfo?.signed);
|
||||
|
||||
return {
|
||||
...jsonRes,
|
||||
...serverInfo,
|
||||
success: true,
|
||||
supportedVersions: supportedVersionsCloud
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...jsonRes,
|
||||
...serverInfo,
|
||||
success: true,
|
||||
supportedVersions
|
||||
};
|
||||
|
|
|
@ -18,4 +18,5 @@ export type TKeyEmitterEvent = keyof TEmitterEvents;
|
|||
|
||||
export const emitter = mitt<TEmitterEvents>();
|
||||
|
||||
emitter.on('*', (type, e) => console.log(type, e));
|
||||
// uncomment the line below to log all events
|
||||
// emitter.on('*', (type, e) => console.log(type, e));
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
import * as FileSystem from 'expo-file-system';
|
||||
import FileViewer from 'react-native-file-viewer';
|
||||
|
||||
import { LISTENER } from '../../../containers/Toast';
|
||||
import { IAttachment } from '../../../definitions';
|
||||
import i18n from '../../../i18n';
|
||||
import EventEmitter from './events';
|
||||
|
||||
export const getLocalFilePathFromFile = (localPath: string, attachment: IAttachment): string => `${localPath}${attachment.title}`;
|
||||
|
||||
export const fileDownload = async (url: string, attachment?: IAttachment, fileName?: string): Promise<string> => {
|
||||
let path = `${FileSystem.documentDirectory}`;
|
||||
if (fileName) {
|
||||
path = `${path}${fileName}`;
|
||||
}
|
||||
if (attachment) {
|
||||
path = `${path}${attachment.title}`;
|
||||
}
|
||||
const file = await FileSystem.downloadAsync(url, path);
|
||||
return file.uri;
|
||||
};
|
||||
|
||||
export const fileDownloadAndPreview = async (url: string, attachment: IAttachment): Promise<void> => {
|
||||
try {
|
||||
const file = await fileDownload(url, attachment);
|
||||
FileViewer.open(file, {
|
||||
showOpenWithDialog: true,
|
||||
showAppsSuggestions: true
|
||||
});
|
||||
} catch (e) {
|
||||
EventEmitter.emit(LISTENER, { message: i18n.t('Error_Download_file') });
|
||||
}
|
||||
};
|
|
@ -0,0 +1,66 @@
|
|||
export interface IFileUpload {
|
||||
name: string;
|
||||
uri?: string;
|
||||
type?: string;
|
||||
filename?: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export class Upload {
|
||||
public xhr: XMLHttpRequest;
|
||||
public formData: FormData;
|
||||
|
||||
constructor() {
|
||||
this.xhr = new XMLHttpRequest();
|
||||
this.formData = new FormData();
|
||||
}
|
||||
|
||||
public setupRequest(url: string, headers: { [key: string]: string }): void {
|
||||
this.xhr.open('POST', url);
|
||||
Object.keys(headers).forEach(key => {
|
||||
this.xhr.setRequestHeader(key, headers[key]);
|
||||
});
|
||||
}
|
||||
|
||||
public appendFile(item: IFileUpload): void {
|
||||
if (item.uri) {
|
||||
this.formData.append(item.name, {
|
||||
uri: item.uri,
|
||||
type: item.type,
|
||||
name: item.filename
|
||||
} as any);
|
||||
} else {
|
||||
this.formData.append(item.name, item.data);
|
||||
}
|
||||
}
|
||||
|
||||
public then(callback: (param: { respInfo: XMLHttpRequest }) => void): void {
|
||||
this.xhr.onload = () => callback({ respInfo: this.xhr });
|
||||
this.xhr.send(this.formData);
|
||||
}
|
||||
|
||||
public catch(callback: ((this: XMLHttpRequest, ev: ProgressEvent<EventTarget>) => any) | null): void {
|
||||
this.xhr.onerror = callback;
|
||||
}
|
||||
|
||||
public uploadProgress(callback: (param: number, arg1: number) => any): void {
|
||||
this.xhr.upload.onprogress = ({ total, loaded }) => callback(loaded, total);
|
||||
}
|
||||
|
||||
public cancel(): Promise<void> {
|
||||
this.xhr.abort();
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
class FileUpload {
|
||||
public uploadFile(url: string, headers: { [x: string]: string }, data: IFileUpload[]) {
|
||||
const upload = new Upload();
|
||||
upload.setupRequest(url, headers);
|
||||
data.forEach(item => upload.appendFile(item));
|
||||
return upload;
|
||||
}
|
||||
}
|
||||
|
||||
const fileUpload = new FileUpload();
|
||||
export default fileUpload;
|
|
@ -1,61 +0,0 @@
|
|||
import { IFileUpload } from './interfaces';
|
||||
|
||||
class Upload {
|
||||
public xhr: XMLHttpRequest;
|
||||
|
||||
public formData: FormData;
|
||||
|
||||
constructor() {
|
||||
this.xhr = new XMLHttpRequest();
|
||||
this.formData = new FormData();
|
||||
}
|
||||
|
||||
then = (callback: (param: { respInfo: XMLHttpRequest }) => XMLHttpRequest) => {
|
||||
this.xhr.onload = () => callback({ respInfo: this.xhr });
|
||||
this.xhr.send(this.formData);
|
||||
};
|
||||
|
||||
catch = (callback: ((this: XMLHttpRequest, ev: ProgressEvent<EventTarget>) => any) | null) => {
|
||||
this.xhr.onerror = callback;
|
||||
};
|
||||
|
||||
uploadProgress = (callback: (param: number, arg1: number) => any) => {
|
||||
this.xhr.upload.onprogress = ({ total, loaded }) => callback(loaded, total);
|
||||
};
|
||||
|
||||
cancel = () => {
|
||||
this.xhr.abort();
|
||||
return Promise.resolve();
|
||||
};
|
||||
}
|
||||
|
||||
class FileUpload {
|
||||
fetch = (method: string, url: string, headers: { [x: string]: string }, data: IFileUpload[]) => {
|
||||
const upload = new Upload();
|
||||
upload.xhr.open(method, url);
|
||||
|
||||
Object.keys(headers).forEach(key => {
|
||||
upload.xhr.setRequestHeader(key, headers[key]);
|
||||
});
|
||||
|
||||
data.forEach(item => {
|
||||
if (item.uri) {
|
||||
// @ts-ignore
|
||||
upload.formData.append(item.name, {
|
||||
// @ts-ignore
|
||||
uri: item.uri,
|
||||
// @ts-ignore
|
||||
type: item.type,
|
||||
name: item.filename
|
||||
});
|
||||
} else {
|
||||
upload.formData.append(item.name, item.data);
|
||||
}
|
||||
});
|
||||
|
||||
return upload;
|
||||
};
|
||||
}
|
||||
|
||||
const fileUpload = new FileUpload();
|
||||
export default fileUpload;
|
|
@ -1,25 +0,0 @@
|
|||
import RNFetchBlob from 'rn-fetch-blob';
|
||||
|
||||
import { TMethods } from '../fetch';
|
||||
import { IFileUpload } from './interfaces';
|
||||
|
||||
class FileUpload {
|
||||
fetch = (method: TMethods, url: string, headers: { [key: string]: string }, data: IFileUpload[]) => {
|
||||
const formData = data.map(item => {
|
||||
if (item.uri) {
|
||||
return {
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
filename: item.filename,
|
||||
data: RNFetchBlob.wrap(decodeURI(item.uri))
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
return RNFetchBlob.fetch(method, url, headers, formData);
|
||||
};
|
||||
}
|
||||
|
||||
const fileUpload = new FileUpload();
|
||||
export default fileUpload;
|
|
@ -1,7 +0,0 @@
|
|||
export interface IFileUpload {
|
||||
name: string;
|
||||
uri?: string;
|
||||
type?: string;
|
||||
filename?: string;
|
||||
data?: any;
|
||||
}
|
|
@ -18,3 +18,4 @@ export * from './image';
|
|||
export * from './askAndroidMediaPermissions';
|
||||
export * from './emitter';
|
||||
export * from './parseJson';
|
||||
export * from './fileDownload';
|
||||
|
|
|
@ -67,6 +67,7 @@ const RCSSLPinning = Platform.select({
|
|||
certificate = persistCertificate(name, certificate.password);
|
||||
}
|
||||
UserPreferences.setMap(extractHostname(server), certificate);
|
||||
SSLPinning?.setCertificate(server, certificate.path, certificate.password);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -1,21 +1,16 @@
|
|||
import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord';
|
||||
import { settings as RocketChatSettings } from '@rocket.chat/sdk';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { FetchBlobResponse, StatefulPromise } from 'rn-fetch-blob';
|
||||
import { Alert } from 'react-native';
|
||||
|
||||
import { Encryption } from '../encryption';
|
||||
import { IUpload, IUser, TUploadModel } from '../../definitions';
|
||||
import i18n from '../../i18n';
|
||||
import database from '../database';
|
||||
import type { IFileUpload, Upload } from './helpers/fileUpload';
|
||||
import FileUpload from './helpers/fileUpload';
|
||||
import { IFileUpload } from './helpers/fileUpload/interfaces';
|
||||
import log from './helpers/log';
|
||||
import { E2E_MESSAGE_TYPE } from '../constants';
|
||||
import { store } from '../store/auxStore';
|
||||
import { compareServerVersion } from './helpers';
|
||||
|
||||
const uploadQueue: { [index: string]: StatefulPromise<FetchBlobResponse> } = {};
|
||||
const uploadQueue: { [index: string]: Upload } = {};
|
||||
|
||||
const getUploadPath = (path: string, rid: string) => `${path}-${rid}`;
|
||||
|
||||
|
@ -52,7 +47,7 @@ export function sendFileMessage(
|
|||
server: string,
|
||||
user: Partial<Pick<IUser, 'id' | 'token'>>,
|
||||
isForceTryAgain?: boolean
|
||||
): Promise<FetchBlobResponse | void> {
|
||||
): Promise<void> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const { id, token } = user;
|
||||
|
@ -89,8 +84,6 @@ export function sendFileMessage(
|
|||
}
|
||||
}
|
||||
|
||||
const encryptedFileInfo = await Encryption.encryptMessage(fileInfo);
|
||||
|
||||
const formData: IFileUpload[] = [];
|
||||
formData.push({
|
||||
name: 'file',
|
||||
|
@ -102,7 +95,7 @@ export function sendFileMessage(
|
|||
if (fileInfo.description) {
|
||||
formData.push({
|
||||
name: 'description',
|
||||
data: encryptedFileInfo.description
|
||||
data: fileInfo.description
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -120,18 +113,6 @@ export function sendFileMessage(
|
|||
});
|
||||
}
|
||||
|
||||
const { version: serverVersion } = store.getState().server;
|
||||
if (encryptedFileInfo.t === E2E_MESSAGE_TYPE && compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '6.8.0')) {
|
||||
formData.push({
|
||||
name: 't',
|
||||
data: encryptedFileInfo.t
|
||||
});
|
||||
formData.push({
|
||||
name: 'e2e',
|
||||
data: encryptedFileInfo.e2e
|
||||
});
|
||||
}
|
||||
|
||||
const headers = {
|
||||
...RocketChatSettings.customHeaders,
|
||||
'Content-Type': 'multipart/form-data',
|
||||
|
@ -139,7 +120,7 @@ export function sendFileMessage(
|
|||
'X-User-Id': id
|
||||
};
|
||||
|
||||
uploadQueue[uploadPath] = FileUpload.fetch('POST', uploadUrl, headers, formData);
|
||||
uploadQueue[uploadPath] = FileUpload.uploadFile(uploadUrl, headers, formData);
|
||||
|
||||
uploadQueue[uploadPath].uploadProgress(async (loaded: number, total: number) => {
|
||||
try {
|
||||
|
@ -155,12 +136,11 @@ export function sendFileMessage(
|
|||
|
||||
uploadQueue[uploadPath].then(async response => {
|
||||
if (response.respInfo.status >= 200 && response.respInfo.status < 400) {
|
||||
// If response is all good...
|
||||
try {
|
||||
await db.write(async () => {
|
||||
await uploadRecord.destroyPermanently();
|
||||
});
|
||||
resolve(response);
|
||||
resolve();
|
||||
} catch (e) {
|
||||
log(e);
|
||||
}
|
||||
|
|
|
@ -8,7 +8,6 @@ import { Encryption } from '../encryption';
|
|||
import { E2EType, IMessage, IUser, TMessageModel } from '../../definitions';
|
||||
import sdk from '../services/sdk';
|
||||
import { E2E_MESSAGE_TYPE, E2E_STATUS, messagesStatus } from '../constants';
|
||||
import { saveDraftMessage } from './draftMessage';
|
||||
|
||||
const changeMessageStatus = async (id: string, status: number, tmid?: string, message?: IMessage) => {
|
||||
const db = database.active;
|
||||
|
@ -232,9 +231,6 @@ export async function sendMessage(
|
|||
}
|
||||
|
||||
await sendMessageCall(message);
|
||||
// clear draft message when message is sent and app is in background or closed
|
||||
// do not affect the user experience when the app is in the foreground because the hook useAutoSaveDraft will handle it
|
||||
saveDraftMessage({ rid, tmid, draftMessage: '' });
|
||||
} catch (e) {
|
||||
log(e);
|
||||
}
|
||||
|
|
|
@ -1,13 +1,9 @@
|
|||
import RNFetchBlob from 'rn-fetch-blob';
|
||||
|
||||
export const getServerTimeSync = async (server: string) => {
|
||||
try {
|
||||
const response = await Promise.race([
|
||||
RNFetchBlob.fetch('GET', `${server}/_timesync`),
|
||||
new Promise<undefined>(res => setTimeout(res, 2000))
|
||||
]);
|
||||
if (response?.data) {
|
||||
return parseInt(response.data);
|
||||
const response = await Promise.race([fetch(`${server}/_timesync`), new Promise<undefined>(res => setTimeout(res, 2000))]);
|
||||
const data = await response?.json();
|
||||
if (data?.data) {
|
||||
return parseInt(data.data);
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
|
|
|
@ -276,7 +276,7 @@ export type InsideStackParamList = {
|
|||
serverInfo: IServer;
|
||||
text: string;
|
||||
room: TSubscriptionModel;
|
||||
thread: TThreadModel;
|
||||
thread: TThreadModel | string;
|
||||
action: TMessageAction;
|
||||
finishShareView: (text?: string, selectedMessages?: string[]) => void | undefined;
|
||||
startShareView: () => { text: string; selectedMessages: string[] };
|
||||
|
|
|
@ -6,7 +6,7 @@ import React from 'react';
|
|||
import { PermissionsAndroid, useWindowDimensions, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { shallowEqual } from 'react-redux';
|
||||
import RNFetchBlob from 'rn-fetch-blob';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
|
||||
import { isImageBase64 } from '../lib/methods';
|
||||
import RCActivityIndicator from '../containers/ActivityIndicator';
|
||||
|
@ -18,7 +18,7 @@ import { IAttachment } from '../definitions';
|
|||
import I18n from '../i18n';
|
||||
import { useAppSelector } from '../lib/hooks';
|
||||
import { useAppNavigation, useAppRoute } from '../lib/hooks/navigation';
|
||||
import { formatAttachmentUrl, isAndroid } from '../lib/methods/helpers';
|
||||
import { formatAttachmentUrl, isAndroid, fileDownload } from '../lib/methods/helpers';
|
||||
import EventEmitter from '../lib/methods/helpers/events';
|
||||
import { getUserSelector } from '../selectors/login';
|
||||
import { TNavigation } from '../stacks/stackType';
|
||||
|
@ -177,11 +177,9 @@ const AttachmentView = (): React.ReactElement => {
|
|||
} else {
|
||||
filename = getFilename({ title: attachment.title, type: 'video', mimeType: video_type, url });
|
||||
}
|
||||
const documentDir = `${RNFetchBlob.fs.dirs.DocumentDir}/`;
|
||||
const path = `${documentDir + filename}`;
|
||||
const file = await RNFetchBlob.config({ path }).fetch('GET', mediaAttachment);
|
||||
await CameraRoll.save(path, { album: 'Rocket.Chat' });
|
||||
file.flush();
|
||||
const file = await fileDownload(mediaAttachment, {}, filename);
|
||||
await CameraRoll.save(file, { album: 'Rocket.Chat' });
|
||||
FileSystem.deleteAsync(file, { idempotent: true });
|
||||
}
|
||||
EventEmitter.emit(LISTENER, { message: I18n.t('saved_to_gallery') });
|
||||
} catch (e) {
|
||||
|
|
|
@ -45,7 +45,7 @@ describe('SwitchItemEncrypted', () => {
|
|||
const component = screen.queryByTestId(testEncrypted.testSwitchID);
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
it('should change value of switch', () => {
|
||||
render(
|
||||
<SwitchItemEncrypted
|
||||
|
@ -62,7 +62,7 @@ describe('SwitchItemEncrypted', () => {
|
|||
expect(onPressMock).toHaveReturnedWith({ value: !testEncrypted.encrypted });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
it('label when encrypted and isTeam are false and is a public channel', () => {
|
||||
render(
|
||||
<SwitchItemEncrypted
|
||||
|
@ -76,7 +76,7 @@ describe('SwitchItemEncrypted', () => {
|
|||
const component = screen.queryByTestId(testEncrypted.testLabelID);
|
||||
expect(component?.props.children).toBe(i18n.t('Channel_hint_encrypted_not_available'));
|
||||
});
|
||||
|
||||
|
||||
it('label when encrypted and isTeam are true and is a private team', () => {
|
||||
testEncrypted.isTeam = true;
|
||||
testEncrypted.type = true;
|
||||
|
|
|
@ -985,7 +985,9 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
|
|||
const { rid } = this.state.room;
|
||||
const { user } = this.props;
|
||||
sendMessage(rid, message, this.tmid, user, tshow).then(() => {
|
||||
this.setLastOpen(null);
|
||||
if (this.mounted) {
|
||||
this.setLastOpen(null);
|
||||
}
|
||||
Review.pushPositiveEvent();
|
||||
});
|
||||
this.resetAction();
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import I18n from '../../i18n';
|
||||
|
@ -9,6 +9,7 @@ import sharedStyles from '../Styles';
|
|||
import { makeThreadName } from '../../lib/methods/helpers/room';
|
||||
import { ISubscription, TThreadModel } from '../../definitions';
|
||||
import { getRoomTitle, isGroupChat, isAndroid, isTablet } from '../../lib/methods/helpers';
|
||||
import { getMessageById } from '../../lib/database/services/Message';
|
||||
|
||||
const androidMarginLeft = isTablet ? 0 : 4;
|
||||
|
||||
|
@ -36,13 +37,14 @@ const styles = StyleSheet.create({
|
|||
|
||||
interface IHeader {
|
||||
room: ISubscription;
|
||||
thread: TThreadModel;
|
||||
thread: TThreadModel | string;
|
||||
}
|
||||
|
||||
const Header = React.memo(({ room, thread }: IHeader) => {
|
||||
const [title, setTitle] = useState('');
|
||||
const { theme } = useTheme();
|
||||
let type;
|
||||
if (thread?.id) {
|
||||
if ((thread as TThreadModel)?.id || typeof thread === 'string') {
|
||||
type = 'thread';
|
||||
} else if (room?.prid) {
|
||||
type = 'discussion';
|
||||
|
@ -70,12 +72,28 @@ const Header = React.memo(({ room, thread }: IHeader) => {
|
|||
|
||||
const textColor = themes[theme].fontDefault;
|
||||
|
||||
let title;
|
||||
if (thread?.id) {
|
||||
title = makeThreadName(thread);
|
||||
} else {
|
||||
title = getRoomTitle(room);
|
||||
}
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if ((thread as TThreadModel)?.id) {
|
||||
const name = makeThreadName(thread as TThreadModel);
|
||||
if (name) {
|
||||
setTitle(name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (typeof thread === 'string') {
|
||||
// only occurs when sending images and there is no message in the thread
|
||||
const data = await getMessageById(thread);
|
||||
const msg = data?.asPlain()?.msg;
|
||||
if (msg) {
|
||||
setTitle(msg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const name = getRoomTitle(room);
|
||||
setTitle(name);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
|
|
|
@ -43,7 +43,7 @@ interface IShareViewState {
|
|||
attachments: IShareAttachment[];
|
||||
text: string;
|
||||
room: TSubscriptionModel;
|
||||
thread: TThreadModel;
|
||||
thread: TThreadModel | string;
|
||||
maxFileSize?: number;
|
||||
mediaAllowList?: string;
|
||||
selectedMessages: string[];
|
||||
|
@ -88,7 +88,7 @@ class ShareView extends Component<IShareViewProps, IShareViewState> {
|
|||
attachments: [],
|
||||
text: props.route.params?.text ?? '',
|
||||
room: props.route.params?.room ?? {},
|
||||
thread: props.route.params?.thread ?? {},
|
||||
thread: props.route.params?.thread ?? '',
|
||||
maxFileSize: this.isShareExtension ? this.serverInfo?.FileUpload_MaxFileSize : props.FileUpload_MaxFileSize,
|
||||
mediaAllowList: this.isShareExtension
|
||||
? this.serverInfo?.FileUpload_MediaTypeWhiteList
|
||||
|
@ -257,7 +257,6 @@ class ShareView extends Component<IShareViewProps, IShareViewState> {
|
|||
return sendFileMessage(
|
||||
room.rid,
|
||||
{
|
||||
rid: room.rid,
|
||||
name,
|
||||
description,
|
||||
size,
|
||||
|
@ -266,7 +265,7 @@ class ShareView extends Component<IShareViewProps, IShareViewState> {
|
|||
store: 'Uploads',
|
||||
msg
|
||||
},
|
||||
thread?.id,
|
||||
(thread as TThreadModel)?.id || (thread as string),
|
||||
server,
|
||||
{ id: user.id, token: user.token }
|
||||
);
|
||||
|
@ -277,7 +276,10 @@ class ShareView extends Component<IShareViewProps, IShareViewState> {
|
|||
|
||||
// Send text message
|
||||
} else if (text.length) {
|
||||
await sendMessage(room.rid, text, thread?.id, { id: user.id, token: user.token } as IUser);
|
||||
await sendMessage(room.rid, text, (thread as TThreadModel)?.id || (thread as string), {
|
||||
id: user.id,
|
||||
token: user.token
|
||||
} as IUser);
|
||||
}
|
||||
} catch {
|
||||
if (!this.isShareExtension) {
|
||||
|
@ -345,14 +347,13 @@ class ShareView extends Component<IShareViewProps, IShareViewState> {
|
|||
value={{
|
||||
rid: room.rid,
|
||||
t: room.t,
|
||||
tmid: thread.id,
|
||||
tmid: (thread as TThreadModel)?.id || (thread as string),
|
||||
sharing: true,
|
||||
action: route.params?.action,
|
||||
selectedMessages,
|
||||
onSendMessage: this.send,
|
||||
onRemoveQuoteMessage: this.onRemoveQuoteMessage
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
<View style={styles.container}>
|
||||
<Preview
|
||||
// using key just to reset zoom/move after change selected
|
||||
|
|
|
@ -194,7 +194,10 @@ describe('Room actions screen', () => {
|
|||
|
||||
// Pin the message
|
||||
await pinMessage(messageToPin);
|
||||
|
||||
// verify pin icon
|
||||
await waitFor(element(by.id(`${messageToPin}-pinned`)))
|
||||
.toExist()
|
||||
.withTimeout(6000);
|
||||
// Back into Room Actions
|
||||
await element(by.id('room-header')).tap();
|
||||
await waitFor(element(by.id('room-actions-view')))
|
||||
|
@ -220,6 +223,10 @@ describe('Room actions screen', () => {
|
|||
await waitFor(element(by[textMatcher](messageToPin).withAncestor(by.id('pinned-messages-view'))))
|
||||
.not.toExist()
|
||||
.withTimeout(6000);
|
||||
// verify pin icon
|
||||
await waitFor(element(by.id(`${messageToPin}-pinned`)))
|
||||
.not.toExist()
|
||||
.withTimeout(6000);
|
||||
await backToActions();
|
||||
});
|
||||
});
|
||||
|
|
After Width: | Height: | Size: 424 KiB |
|
@ -149,6 +149,12 @@
|
|||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"filename" : "1024 1.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "watchos",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
|
After Width: | Height: | Size: 56 KiB |
|
@ -149,6 +149,12 @@
|
|||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"filename" : "1024 1.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "watchos",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
|
|
@ -1233,8 +1233,6 @@ PODS:
|
|||
- React
|
||||
- rn-extensions-share (2.4.1):
|
||||
- React
|
||||
- rn-fetch-blob (0.12.0):
|
||||
- React-Core
|
||||
- RNBootSplash (4.6.0):
|
||||
- React-Core
|
||||
- RNCAsyncStorage (1.22.3):
|
||||
|
@ -1402,7 +1400,6 @@ DEPENDENCIES:
|
|||
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
|
||||
- ReactNativeUiLib (from `../node_modules/react-native-ui-lib`)
|
||||
- rn-extensions-share (from `../node_modules/rn-extensions-share`)
|
||||
- rn-fetch-blob (from `../node_modules/rn-fetch-blob`)
|
||||
- RNBootSplash (from `../node_modules/react-native-bootsplash`)
|
||||
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
|
||||
- "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)"
|
||||
|
@ -1610,8 +1607,6 @@ EXTERNAL SOURCES:
|
|||
:path: "../node_modules/react-native-ui-lib"
|
||||
rn-extensions-share:
|
||||
:path: "../node_modules/rn-extensions-share"
|
||||
rn-fetch-blob:
|
||||
:path: "../node_modules/rn-fetch-blob"
|
||||
RNBootSplash:
|
||||
:path: "../node_modules/react-native-bootsplash"
|
||||
RNCAsyncStorage:
|
||||
|
@ -1760,11 +1755,10 @@ SPEC CHECKSUMS:
|
|||
ReactCommon: 2aa35648354bd4c4665b9a5084a7d37097b89c10
|
||||
ReactNativeUiLib: 33521c0747ea376d292b62b6415e0f1d75bd3c10
|
||||
rn-extensions-share: 5fd84a80e6594706f0dfa1884f2d6d591b382cf5
|
||||
rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba
|
||||
RNBootSplash: 91e0c16bfc96703cb5b0562785b9a8cbfeb298fe
|
||||
RNCAsyncStorage: 10591b9e0a91eaffee14e69b3721009759235125
|
||||
RNCClipboard: 60fed4b71560d7bfe40e9d35dea9762b024da86d
|
||||
RNCMaskedView: 090213d32d8b3bb83a4dcb7d12c18f0152591906
|
||||
RNBootSplash: 7e91ea56c7010aae487489789dbe212e8c905a0c
|
||||
RNCAsyncStorage: 8616bd5a58af409453ea4e1b246521bb76578d60
|
||||
RNCClipboard: cc054ad1e8a33d2a74cd13e565588b4ca928d8fd
|
||||
RNCMaskedView: bc0170f389056201c82a55e242e5d90070e18e5a
|
||||
RNConfigReader: 396da6a6444182a76e8ae0930b9436c7575045cb
|
||||
RNCPicker: b18aaf30df596e9b1738e7c1f9ee55402a229dca
|
||||
RNDateTimePicker: 7b38b71bcd7c4cfa1cb95f2dff9a4f1faed2dced
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>1C8F.1</string>
|
||||
</array>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>E174.1</string>
|
||||
</array>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>3B52.1</string>
|
||||
</array>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>35F9.1</string>
|
||||
</array>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
|
@ -0,0 +1,39 @@
|
|||
import Foundation
|
||||
|
||||
protocol ErrorActionHandling {
|
||||
func handle(error: RocketChatError)
|
||||
}
|
||||
|
||||
final class ErrorActionHandler {
|
||||
@Dependency private var database: Database
|
||||
@Dependency private var serversDB: ServersDatabase
|
||||
@Dependency private var router: AppRouting
|
||||
|
||||
private let server: Server
|
||||
|
||||
init(server: Server) {
|
||||
self.server = server
|
||||
}
|
||||
|
||||
private func handleOnMain(error: RocketChatError) {
|
||||
switch error {
|
||||
case .server(let response):
|
||||
router.present(error: response)
|
||||
case .unauthorized:
|
||||
router.route(to: [.loading, .serverList]) {
|
||||
self.database.remove()
|
||||
self.serversDB.remove(self.server)
|
||||
}
|
||||
case .unknown:
|
||||
print("Unexpected error on Client.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ErrorActionHandler: ErrorActionHandling {
|
||||
func handle(error: RocketChatError) {
|
||||
DispatchQueue.main.async {
|
||||
self.handleOnMain(error: error)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
import Foundation
|
||||
|
||||
protocol AppRouting {
|
||||
func route(to route: Route)
|
||||
func present(error: ErrorResponse)
|
||||
func route(to routes: [Route], completion: (() -> Void)?)
|
||||
}
|
||||
|
||||
final class AppRouter: ObservableObject {
|
||||
@Published var error: ErrorResponse?
|
||||
|
||||
@Published var server: Server? {
|
||||
didSet {
|
||||
if server != oldValue, let server {
|
||||
registerDependencies(in: server)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var room: Room?
|
||||
|
||||
@Storage(.currentServer) private var currentURL: URL?
|
||||
|
||||
private func registerDependencies(in server: Server) {
|
||||
Store.register(Database.self, factory: server.database)
|
||||
Store.register(RocketChatClientProtocol.self, factory: RocketChatClient(server: server))
|
||||
Store.register(MessageSending.self, factory: MessageSender(server: server))
|
||||
Store.register(ErrorActionHandling.self, factory: ErrorActionHandler(server: server))
|
||||
Store.register(MessagesLoading.self, factory: MessagesLoader())
|
||||
Store.register(RoomsLoader.self, factory: RoomsLoader(server: server))
|
||||
}
|
||||
}
|
||||
|
||||
extension AppRouter: AppRouting {
|
||||
func route(to route: Route) {
|
||||
switch route {
|
||||
case .roomList(let selectedServer):
|
||||
currentURL = selectedServer.url
|
||||
room = nil
|
||||
server = selectedServer
|
||||
case .room(let selectedServer, let selectedRoom):
|
||||
currentURL = selectedServer.url
|
||||
server = selectedServer
|
||||
room = selectedRoom
|
||||
case .serverList:
|
||||
currentURL = nil
|
||||
room = nil
|
||||
server = nil
|
||||
case .loading:
|
||||
room = nil
|
||||
server = nil
|
||||
}
|
||||
}
|
||||
|
||||
func present(error: ErrorResponse) {
|
||||
guard self.error == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
self.error = error
|
||||
}
|
||||
}
|
||||
|
||||
extension AppRouter {
|
||||
func route(to routes: [Route], completion: (() -> Void)? = nil) {
|
||||
guard let routeTo = routes.first else {
|
||||
completion?()
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.route(to: routeTo)
|
||||
self.route(to: Array(routes[1..<routes.count]), completion: completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Route: Equatable {
|
||||
case loading
|
||||
case serverList
|
||||
case roomList(Server)
|
||||
case room(Server, Room)
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
import SwiftUI
|
||||
|
||||
struct AppView: View {
|
||||
@Storage(.currentServer) private var currentURL: URL?
|
||||
|
||||
@Dependency private var database: ServersDatabase
|
||||
|
||||
@StateObject private var router: AppRouter
|
||||
|
||||
init(router: AppRouter) {
|
||||
_router = StateObject(wrappedValue: router)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ServerListView()
|
||||
.environmentObject(router)
|
||||
.environment(\.managedObjectContext, database.viewContext)
|
||||
}
|
||||
.onAppear {
|
||||
loadRoute()
|
||||
}
|
||||
.sheet(item: $router.error) { error in
|
||||
Text(error.error)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadRoute() {
|
||||
if let currentURL, let server = database.server(url: currentURL) {
|
||||
router.route(to: .roomList(server))
|
||||
} else if database.servers().count == 1, let server = database.servers().first {
|
||||
router.route(to: .roomList(server))
|
||||
} else {
|
||||
router.route(to: .serverList)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
12
ios/RocketChat Watch App/Assets.xcassets/channel-private.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "channel-private.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
ios/RocketChat Watch App/Assets.xcassets/channel-private.imageset/channel-private.png
vendored
Normal file
After Width: | Height: | Size: 371 B |
12
ios/RocketChat Watch App/Assets.xcassets/channel-public.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "channel-public.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
ios/RocketChat Watch App/Assets.xcassets/channel-public.imageset/channel-public.png
vendored
Normal file
After Width: | Height: | Size: 259 B |
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "discussions.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
ios/RocketChat Watch App/Assets.xcassets/discussions.imageset/discussions.png
vendored
Normal file
After Width: | Height: | Size: 517 B |
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "message.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 479 B |
12
ios/RocketChat Watch App/Assets.xcassets/teams-private.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "teams-private.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
ios/RocketChat Watch App/Assets.xcassets/teams-private.imageset/teams-private.png
vendored
Normal file
After Width: | Height: | Size: 509 B |
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "teams.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 512 B |
|
@ -0,0 +1,10 @@
|
|||
import Foundation
|
||||
|
||||
struct JSONAdapter: RequestAdapter {
|
||||
func adapt(_ urlRequest: URLRequest) -> URLRequest {
|
||||
var request = urlRequest
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept")
|
||||
return request
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import Foundation
|
||||
|
||||
protocol RequestAdapter {
|
||||
func adapt(_ urlRequest: URLRequest) -> URLRequest
|
||||
func adapt(_ url: URL) -> URL
|
||||
}
|
||||
|
||||
extension RequestAdapter {
|
||||
func adapt(_ url: URL) -> URL {
|
||||
url
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import Foundation
|
||||
|
||||
struct TokenAdapter: RequestAdapter {
|
||||
private let server: Server
|
||||
|
||||
init(server: Server) {
|
||||
self.server = server
|
||||
}
|
||||
|
||||
func adapt(_ url: URL) -> URL {
|
||||
url.appending(
|
||||
queryItems: [
|
||||
URLQueryItem(name: "rc_token", value: server.loggedUser.token),
|
||||
URLQueryItem(name: "rc_uid", value: server.loggedUser.id)
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func adapt(_ urlRequest: URLRequest) -> URLRequest {
|
||||
var request = urlRequest
|
||||
request.addValue(server.loggedUser.id, forHTTPHeaderField: "x-user-id")
|
||||
request.addValue(server.loggedUser.token, forHTTPHeaderField: "x-auth-token")
|
||||
return request
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
// https://stackoverflow.com/a/28016692
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Date.ISO8601FormatStyle {
|
||||
static let iso8601withFractionalSeconds: Self = .init(includingFractionalSeconds: true)
|
||||
}
|
||||
|
||||
extension ParseStrategy where Self == Date.ISO8601FormatStyle {
|
||||
static var iso8601withFractionalSeconds: Date.ISO8601FormatStyle { .iso8601withFractionalSeconds }
|
||||
}
|
||||
|
||||
extension FormatStyle where Self == Date.ISO8601FormatStyle {
|
||||
static var iso8601withFractionalSeconds: Date.ISO8601FormatStyle { .iso8601withFractionalSeconds }
|
||||
}
|
||||
|
||||
extension Date {
|
||||
init(iso8601withFractionalSeconds parseInput: ParseStrategy.ParseInput) throws {
|
||||
try self.init(parseInput, strategy: .iso8601withFractionalSeconds)
|
||||
}
|
||||
|
||||
var iso8601withFractionalSeconds: String {
|
||||
formatted(.iso8601withFractionalSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
func iso8601withFractionalSeconds() throws -> Date {
|
||||
try .init(iso8601withFractionalSeconds: self)
|
||||
}
|
||||
}
|
||||
|
||||
extension JSONDecoder.DateDecodingStrategy {
|
||||
static let iso8601withFractionalSeconds = custom {
|
||||
try .init(iso8601withFractionalSeconds: $0.singleValueContainer().decode(String.self))
|
||||
}
|
||||
}
|
||||
|
||||
extension JSONEncoder.DateEncodingStrategy {
|
||||
static let iso8601withFractionalSeconds = custom {
|
||||
var container = $1.singleValueContainer()
|
||||
try container.encode($0.iso8601withFractionalSeconds)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import Foundation
|
||||
|
||||
extension Data {
|
||||
func decode<T: Decodable>(_ type: T.Type) throws -> T {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601withFractionalSeconds
|
||||
return try decoder.decode(T.self, from: self)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
extension String {
|
||||
static func random(_ count: Int) -> String {
|
||||
let letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
return String((0..<count).compactMap { _ in letters.randomElement() })
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import Foundation
|
||||
|
||||
extension URL {
|
||||
func appending(queryItems: [URLQueryItem]) -> Self {
|
||||
var components = URLComponents(url: self, resolvingAgainstBaseURL: true)
|
||||
|
||||
components?.queryItems = queryItems
|
||||
|
||||
return components?.url ?? self
|
||||
}
|
||||
|
||||
func appending(path: String) -> Self {
|
||||
appendingPathComponent(path)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
struct FailableDecodable<Value: Codable & Hashable>: Codable, Hashable {
|
||||
let value: Value?
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
value = try? container.decode(Value.self)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
try container.encode(value)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
enum HTTPMethod: String {
|
||||
case get = "GET"
|
||||
case post = "POST"
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
import Foundation
|
||||
|
||||
protocol Request<Response> {
|
||||
associatedtype Response: Codable
|
||||
|
||||
var path: String { get }
|
||||
var method: HTTPMethod { get }
|
||||
var body: Data? { get }
|
||||
var queryItems: [URLQueryItem] { get }
|
||||
}
|
||||
|
||||
extension Request {
|
||||
var method: HTTPMethod {
|
||||
.get
|
||||
}
|
||||
|
||||
var body: Data? {
|
||||
nil
|
||||
}
|
||||
|
||||
var queryItems: [URLQueryItem] {
|
||||
[]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
import Foundation
|
||||
|
||||
let HISTORY_MESSAGE_COUNT = 50
|
||||
|
||||
struct HistoryRequest: Request {
|
||||
typealias Response = HistoryResponse
|
||||
|
||||
let path: String
|
||||
let queryItems: [URLQueryItem]
|
||||
|
||||
init(roomId: String, roomType: String?, latest: Date) {
|
||||
path = "api/v1/\(RoomType.from(roomType).api).history"
|
||||
|
||||
queryItems = [
|
||||
URLQueryItem(name: "roomId", value: roomId),
|
||||
URLQueryItem(name: "count", value: String(HISTORY_MESSAGE_COUNT)),
|
||||
URLQueryItem(name: "latest", value: latest.iso8601withFractionalSeconds)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate enum RoomType: String {
|
||||
case direct = "d"
|
||||
case group = "p"
|
||||
case channel = "c"
|
||||
case livechat = "l"
|
||||
|
||||
static func from(_ rawValue: String?) -> Self {
|
||||
guard let rawValue, let type = RoomType(rawValue: rawValue) else {
|
||||
return .channel
|
||||
}
|
||||
|
||||
return type
|
||||
}
|
||||
|
||||
var api: String {
|
||||
switch self {
|
||||
case .direct:
|
||||
return "im"
|
||||
case .group:
|
||||
return "groups"
|
||||
case .channel, .livechat:
|
||||
return "channels"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import Foundation
|
||||
|
||||
struct MessagesRequest: Request {
|
||||
typealias Response = MessagesResponse
|
||||
|
||||
let path: String = "api/v1/chat.syncMessages"
|
||||
let queryItems: [URLQueryItem]
|
||||
|
||||
init(lastUpdate: Date?, roomId: String) {
|
||||
self.queryItems = [
|
||||
URLQueryItem(name: "roomId", value: roomId),
|
||||
URLQueryItem(name: "lastUpdate", value: lastUpdate?.ISO8601Format())
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import Foundation
|
||||
|
||||
struct ReadRequest: Request {
|
||||
typealias Response = ReadResponse
|
||||
|
||||
let path: String = "api/v1/subscriptions.read"
|
||||
let method: HTTPMethod = .post
|
||||
|
||||
var body: Data? {
|
||||
try? JSONSerialization.data(withJSONObject: [
|
||||
"rid": rid
|
||||
])
|
||||
}
|
||||
|
||||
let rid: String
|
||||
|
||||
init(rid: String) {
|
||||
self.rid = rid
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
import Foundation
|
||||
|
||||
struct RoomsRequest: Request {
|
||||
typealias Response = RoomsResponse
|
||||
|
||||
let path: String = "api/v1/rooms.get"
|
||||
let queryItems: [URLQueryItem]
|
||||
|
||||
init(updatedSince: Date?) {
|
||||
if let updatedSince {
|
||||
queryItems = [URLQueryItem(name: "updatedSince", value: updatedSince.iso8601withFractionalSeconds)]
|
||||
} else {
|
||||
queryItems = []
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import Foundation
|
||||
|
||||
struct SendMessageRequest: Request {
|
||||
typealias Response = SendMessageResponse
|
||||
|
||||
let path: String = "api/v1/chat.sendMessage"
|
||||
let method: HTTPMethod = .post
|
||||
|
||||
var body: Data? {
|
||||
try? JSONSerialization.data(withJSONObject: [
|
||||
"message": [
|
||||
"_id": id,
|
||||
"rid": rid,
|
||||
"msg": msg,
|
||||
"tshow": false
|
||||
]
|
||||
])
|
||||
}
|
||||
|
||||
let id: String
|
||||
let rid: String
|
||||
let msg: String
|
||||
|
||||
init(id: String, rid: String, msg: String) {
|
||||
self.id = id
|
||||
self.rid = rid
|
||||
self.msg = msg
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
import Foundation
|
||||
|
||||
struct SubscriptionsRequest: Request {
|
||||
typealias Response = SubscriptionsResponse
|
||||
|
||||
let path: String = "api/v1/subscriptions.get"
|
||||
let queryItems: [URLQueryItem]
|
||||
|
||||
init(updatedSince: Date?) {
|
||||
if let updatedSince {
|
||||
queryItems = [URLQueryItem(name: "updatedSince", value: updatedSince.iso8601withFractionalSeconds)]
|
||||
} else {
|
||||
queryItems = []
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
import Foundation
|
||||
|
||||
struct AttachmentResponse: Codable, Hashable {
|
||||
let title: String?
|
||||
let imageURL: URL?
|
||||
let audioURL: URL?
|
||||
let description: String?
|
||||
let dimensions: DimensionsResponse?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case imageURL = "image_url"
|
||||
case audioURL = "audio_url"
|
||||
case title
|
||||
case description
|
||||
case dimensions = "image_dimensions"
|
||||
}
|
||||
}
|
||||
|
||||
struct DimensionsResponse: Codable, Hashable {
|
||||
let width: Double
|
||||
let height: Double
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import Foundation
|
||||
|
||||
struct HistoryResponse: Codable {
|
||||
let messages: [MessageResponse]
|
||||
let success: Bool
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import Foundation
|
||||
|
||||
struct MessageResponse: Codable, Hashable {
|
||||
let _id: String
|
||||
let rid: String
|
||||
let msg: String
|
||||
let u: UserResponse
|
||||
let ts: Date
|
||||
let attachments: [AttachmentResponse]?
|
||||
let t: String?
|
||||
let groupable: Bool?
|
||||
let editedAt: Date?
|
||||
let role: String?
|
||||
let comment: String?
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import Foundation
|
||||
|
||||
struct MessagesResponse: Codable {
|
||||
let result: MessagesResult
|
||||
let success: Bool
|
||||
|
||||
struct MessagesResult: Codable {
|
||||
let updated: [MessageResponse]
|
||||
let deleted: [MessageResponse]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import Foundation
|
||||
|
||||
struct ReadResponse: Codable {
|
||||
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import Foundation
|
||||
|
||||
struct RoomsResponse: Codable {
|
||||
let update: Set<Room>
|
||||
let remove: Set<Room>
|
||||
let success: Bool
|
||||
|
||||
struct Room: Codable, Hashable {
|
||||
let _id: String
|
||||
let name: String?
|
||||
let fname: String?
|
||||
let prid: String?
|
||||
let t: String?
|
||||
let ts: Date?
|
||||
let ro: Bool?
|
||||
let _updatedAt: Date?
|
||||
let encrypted: Bool?
|
||||
let usernames: [String]?
|
||||
let uids: [String]?
|
||||
let lastMessage: FailableDecodable<MessageResponse>?
|
||||
let lm: Date?
|
||||
let teamMain: Bool?
|
||||
let archived: Bool?
|
||||
let broadcast: Bool?
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import Foundation
|
||||
|
||||
struct SendMessageResponse: Codable {
|
||||
let message: MessageResponse
|
||||
let success: Bool
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import Foundation
|
||||
|
||||
struct SubscriptionsResponse: Codable {
|
||||
let update: Set<Subscription>
|
||||
let remove: Set<Subscription>
|
||||
let success: Bool
|
||||
|
||||
struct Subscription: Codable, Hashable {
|
||||
let _id: String
|
||||
let rid: String
|
||||
let name: String?
|
||||
let fname: String?
|
||||
let t: String
|
||||
let unread: Int
|
||||
let alert: Bool
|
||||
let lr: Date?
|
||||
let open: Bool?
|
||||
let _updatedAt: Date?
|
||||
let hideUnreadStatus: Bool?
|
||||
}
|
||||
}
|
||||
|
||||
extension Sequence where Element == SubscriptionsResponse.Subscription {
|
||||
func find(withRoomID rid: String) -> SubscriptionsResponse.Subscription? {
|
||||
first { subscription in
|
||||
subscription.rid == rid
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import Foundation
|
||||
|
||||
struct UserResponse: Codable, Hashable {
|
||||
let _id: String
|
||||
let username: String
|
||||
let name: String?
|
||||
}
|
|
@ -0,0 +1,107 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
|
||||
protocol RocketChatClientProtocol {
|
||||
var session: URLSession { get }
|
||||
|
||||
func authorizedURL(url: URL) -> URL
|
||||
func getRooms(updatedSince: Date?) -> AnyPublisher<RoomsResponse, RocketChatError>
|
||||
func getSubscriptions(updatedSince: Date?) -> AnyPublisher<SubscriptionsResponse, RocketChatError>
|
||||
func getHistory(rid: String, t: String, latest: Date) -> AnyPublisher<HistoryResponse, RocketChatError>
|
||||
func syncMessages(rid: String, updatedSince: Date) -> AnyPublisher<MessagesResponse, RocketChatError>
|
||||
func sendMessage(id: String, rid: String, msg: String) -> AnyPublisher<SendMessageResponse, RocketChatError>
|
||||
func sendRead(rid: String) -> AnyPublisher<ReadResponse, RocketChatError>
|
||||
}
|
||||
|
||||
final class RocketChatClient: NSObject {
|
||||
@Dependency private var errorActionHandler: ErrorActionHandling
|
||||
|
||||
private let server: Server
|
||||
|
||||
init(server: Server) {
|
||||
self.server = server
|
||||
}
|
||||
|
||||
lazy var session = URLSession(
|
||||
configuration: .default,
|
||||
delegate: URLSesionClientCertificateHandling(
|
||||
certificate: server.certificate,
|
||||
password: server.password
|
||||
),
|
||||
delegateQueue: nil
|
||||
)
|
||||
|
||||
private var adapters: [RequestAdapter] {
|
||||
[
|
||||
TokenAdapter(server: server),
|
||||
JSONAdapter()
|
||||
]
|
||||
}
|
||||
|
||||
private func dataTask<T: Request>(for request: T) -> AnyPublisher<T.Response, RocketChatError> {
|
||||
let url = server.url.appending(path: request.path).appending(queryItems: request.queryItems)
|
||||
|
||||
var urlRequest = adapters.reduce(URLRequest(url: url), { $1.adapt($0) })
|
||||
urlRequest.httpMethod = request.method.rawValue
|
||||
urlRequest.httpBody = request.body
|
||||
|
||||
return session.dataTaskPublisher(for: urlRequest)
|
||||
.tryMap { data, response in
|
||||
if let response = response as? HTTPURLResponse, response.statusCode == 401 {
|
||||
throw RocketChatError.unauthorized
|
||||
}
|
||||
|
||||
if let response = try? data.decode(T.Response.self) {
|
||||
return response
|
||||
}
|
||||
|
||||
let response = try data.decode(ErrorResponse.self)
|
||||
throw RocketChatError.server(response: response)
|
||||
}
|
||||
.mapError { [weak self] error in
|
||||
guard let error = error as? RocketChatError else {
|
||||
return .unknown
|
||||
}
|
||||
|
||||
self?.errorActionHandler.handle(error: error)
|
||||
return error
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
extension RocketChatClient: RocketChatClientProtocol {
|
||||
func authorizedURL(url: URL) -> URL {
|
||||
adapters.reduce(server.url.appending(path: url.relativePath), { $1.adapt($0) })
|
||||
}
|
||||
|
||||
func getRooms(updatedSince: Date?) -> AnyPublisher<RoomsResponse, RocketChatError> {
|
||||
let request = RoomsRequest(updatedSince: updatedSince)
|
||||
return dataTask(for: request)
|
||||
}
|
||||
|
||||
func getSubscriptions(updatedSince: Date?) -> AnyPublisher<SubscriptionsResponse, RocketChatError> {
|
||||
let request = SubscriptionsRequest(updatedSince: updatedSince)
|
||||
return dataTask(for: request)
|
||||
}
|
||||
|
||||
func getHistory(rid: String, t: String, latest: Date) -> AnyPublisher<HistoryResponse, RocketChatError> {
|
||||
let request = HistoryRequest(roomId: rid, roomType: t, latest: latest)
|
||||
return dataTask(for: request)
|
||||
}
|
||||
|
||||
func syncMessages(rid: String, updatedSince: Date) -> AnyPublisher<MessagesResponse, RocketChatError> {
|
||||
let request = MessagesRequest(lastUpdate: updatedSince, roomId: rid)
|
||||
return dataTask(for: request)
|
||||
}
|
||||
|
||||
func sendMessage(id: String, rid: String, msg: String) -> AnyPublisher<SendMessageResponse, RocketChatError> {
|
||||
let request = SendMessageRequest(id: id, rid: rid, msg: msg)
|
||||
return dataTask(for: request)
|
||||
}
|
||||
|
||||
func sendRead(rid: String) -> AnyPublisher<ReadResponse, RocketChatError> {
|
||||
let request = ReadRequest(rid: rid)
|
||||
return dataTask(for: request)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import Foundation
|
||||
|
||||
struct ErrorResponse: Codable, Identifiable {
|
||||
var id: String {
|
||||
error
|
||||
}
|
||||
|
||||
let error: String
|
||||
}
|
||||
|
||||
enum RocketChatError: Error {
|
||||
case server(response: ErrorResponse)
|
||||
case unauthorized
|
||||
case unknown
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
// https://medium.com/@hamidptb/implementing-mtls-on-ios-using-urlsession-and-cloudflare-890b76aca66c
|
||||
|
||||
import Foundation
|
||||
|
||||
final class URLSesionClientCertificateHandling: NSObject, URLSessionDelegate {
|
||||
private let certificate: Data?
|
||||
private let password: String?
|
||||
|
||||
init(certificate: Data?, password: String?) {
|
||||
self.certificate = certificate
|
||||
self.password = password
|
||||
}
|
||||
|
||||
public func urlSession(
|
||||
_: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate else {
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
return
|
||||
}
|
||||
|
||||
guard let credential = Credentials.urlCredential(certificate: certificate, password: password) else {
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
return
|
||||
}
|
||||
|
||||
challenge.sender?.use(credential, for: challenge)
|
||||
completionHandler(.useCredential, credential)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate typealias UserCertificate = (data: Data, password: String)
|
||||
|
||||
fileprivate final class Credentials {
|
||||
static func urlCredential(certificate: Data?, password: String?) -> URLCredential? {
|
||||
guard let certificate, let password else { return nil }
|
||||
|
||||
let p12Contents = PKCS12(pkcs12Data: certificate, password: password)
|
||||
|
||||
guard let identity = p12Contents.identity else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return URLCredential(identity: identity, certificates: nil, persistence: .none)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct PKCS12 {
|
||||
let label: String?
|
||||
let keyID: NSData?
|
||||
let trust: SecTrust?
|
||||
let certChain: [SecTrust]?
|
||||
let identity: SecIdentity?
|
||||
|
||||
public init(pkcs12Data: Data, password: String) {
|
||||
let importPasswordOption: NSDictionary
|
||||
= [kSecImportExportPassphrase as NSString: password]
|
||||
var items: CFArray?
|
||||
let secError: OSStatus
|
||||
= SecPKCS12Import(pkcs12Data as NSData,
|
||||
importPasswordOption, &items)
|
||||
guard secError == errSecSuccess else {
|
||||
if secError == errSecAuthFailed {
|
||||
NSLog("Incorrect password?")
|
||||
}
|
||||
fatalError("Error trying to import PKCS12 data")
|
||||
}
|
||||
guard let theItemsCFArray = items else { fatalError() }
|
||||
let theItemsNSArray: NSArray = theItemsCFArray as NSArray
|
||||
guard let dictArray
|
||||
= theItemsNSArray as? [[String: AnyObject]]
|
||||
else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
label = dictArray.element(for: kSecImportItemLabel)
|
||||
keyID = dictArray.element(for: kSecImportItemKeyID)
|
||||
trust = dictArray.element(for: kSecImportItemTrust)
|
||||
certChain = dictArray.element(for: kSecImportItemCertChain)
|
||||
identity = dictArray.element(for: kSecImportItemIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension Array where Element == [String: AnyObject] {
|
||||
func element<T>(for key: CFString) -> T? {
|
||||
for dictElement in self {
|
||||
if let value = dictElement[key as String] as? T {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
|
@ -0,0 +1,132 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
|
||||
protocol ServersDatabase {
|
||||
var viewContext: NSManagedObjectContext { get }
|
||||
|
||||
func server(url: URL) -> Server?
|
||||
func user(id: String) -> LoggedUser?
|
||||
func servers() -> [Server]
|
||||
|
||||
func remove(_ server: Server)
|
||||
|
||||
func save()
|
||||
|
||||
func process(updatedServer: WatchMessage.Server)
|
||||
}
|
||||
|
||||
final class DefaultDatabase: ServersDatabase {
|
||||
private let container: NSPersistentContainer
|
||||
|
||||
var viewContext: NSManagedObjectContext {
|
||||
container.viewContext
|
||||
}
|
||||
|
||||
private static let model: NSManagedObjectModel = {
|
||||
guard let url = Bundle.main.url(forResource: "Default", withExtension: "momd"),
|
||||
let managedObjectModel = NSManagedObjectModel(contentsOf: url) else {
|
||||
fatalError("Can't find Core Data Model")
|
||||
}
|
||||
|
||||
return managedObjectModel
|
||||
}()
|
||||
|
||||
init() {
|
||||
container = NSPersistentContainer(name: "default", managedObjectModel: Self.model)
|
||||
|
||||
container.loadPersistentStores { _, error in
|
||||
if let error { fatalError("Can't load persistent stores: \(error)") }
|
||||
}
|
||||
|
||||
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||
}
|
||||
|
||||
func save() {
|
||||
guard container.viewContext.hasChanges else {
|
||||
return
|
||||
}
|
||||
|
||||
try? container.viewContext.save()
|
||||
}
|
||||
|
||||
func server(url: URL) -> Server? {
|
||||
let request = Server.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "url == %@", url.absoluteString.removeTrailingSlash())
|
||||
|
||||
return try? viewContext.fetch(request).first
|
||||
}
|
||||
|
||||
func user(id: String) -> LoggedUser? {
|
||||
let request = LoggedUser.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id == %@", id)
|
||||
|
||||
return try? viewContext.fetch(request).first
|
||||
}
|
||||
|
||||
func servers() -> [Server] {
|
||||
let request = Server.fetchRequest()
|
||||
|
||||
return (try? viewContext.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
func remove(_ server: Server) {
|
||||
viewContext.delete(server)
|
||||
|
||||
save()
|
||||
}
|
||||
|
||||
func process(updatedServer: WatchMessage.Server) {
|
||||
if let server = server(url: updatedServer.url) {
|
||||
server.url = updatedServer.url
|
||||
server.name = updatedServer.name
|
||||
server.iconURL = updatedServer.iconURL
|
||||
server.useRealName = updatedServer.useRealName
|
||||
server.loggedUser = user(from: updatedServer.loggedUser)
|
||||
server.certificate = updatedServer.clientSSL?.certificate
|
||||
server.password = updatedServer.clientSSL?.password
|
||||
server.version = updatedServer.version
|
||||
} else {
|
||||
Server(
|
||||
context: viewContext,
|
||||
iconURL: updatedServer.iconURL,
|
||||
name: updatedServer.name,
|
||||
url: updatedServer.url,
|
||||
useRealName: updatedServer.useRealName,
|
||||
loggedUser: user(from: updatedServer.loggedUser),
|
||||
certificate: updatedServer.clientSSL?.certificate,
|
||||
password: updatedServer.clientSSL?.password,
|
||||
version: updatedServer.version
|
||||
)
|
||||
}
|
||||
|
||||
save()
|
||||
}
|
||||
|
||||
private func user(from updatedUser: WatchMessage.Server.LoggedUser) -> LoggedUser {
|
||||
if let user = user(id: updatedUser.id) {
|
||||
user.name = updatedUser.name
|
||||
user.username = updatedUser.username
|
||||
user.token = updatedUser.token
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
return LoggedUser(
|
||||
context: viewContext,
|
||||
id: updatedUser.id,
|
||||
name: updatedUser.name,
|
||||
token: updatedUser.token,
|
||||
username: updatedUser.username
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
func removeTrailingSlash() -> String {
|
||||
var url = self
|
||||
if (url.last == "/") {
|
||||
url.removeLast()
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22222" systemVersion="23C64" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
|
||||
<entity name="LoggedUser" representedClassName=".LoggedUser" syncable="YES">
|
||||
<attribute name="id" optional="YES" attributeType="String"/>
|
||||
<attribute name="name" optional="YES" attributeType="String"/>
|
||||
<attribute name="token" optional="YES" attributeType="String"/>
|
||||
<attribute name="username" optional="YES" attributeType="String"/>
|
||||
<relationship name="server" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Server" inverseName="loggedUser" inverseEntity="Server"/>
|
||||
</entity>
|
||||
<entity name="Server" representedClassName=".Server" syncable="YES">
|
||||
<attribute name="certificate" optional="YES" attributeType="Binary"/>
|
||||
<attribute name="iconURL" optional="YES" attributeType="URI"/>
|
||||
<attribute name="name" optional="YES" attributeType="String"/>
|
||||
<attribute name="password" optional="YES" attributeType="String"/>
|
||||
<attribute name="updatedSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="url" optional="YES" attributeType="URI"/>
|
||||
<attribute name="useRealName" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="version" optional="YES" attributeType="String"/>
|
||||
<relationship name="loggedUser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="LoggedUser" inverseName="server" inverseEntity="LoggedUser"/>
|
||||
</entity>
|
||||
</model>
|
|
@ -0,0 +1,7 @@
|
|||
import CoreData
|
||||
|
||||
extension Attachment {
|
||||
var aspectRatio: Double {
|
||||
return width / height
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import CoreData
|
||||
|
||||
@objc
|
||||
public final class LoggedUser: NSManagedObject {
|
||||
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<LoggedUser> {
|
||||
NSFetchRequest<LoggedUser>(entityName: "LoggedUser")
|
||||
}
|
||||
|
||||
@NSManaged public var id: String
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var token: String
|
||||
@NSManaged public var username: String
|
||||
|
||||
@available(*, unavailable)
|
||||
init() {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
init(context: NSManagedObjectContext) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
public override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?) {
|
||||
super.init(entity: entity, insertInto: context)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public init(
|
||||
context: NSManagedObjectContext,
|
||||
id: String,
|
||||
name: String,
|
||||
token: String,
|
||||
username: String
|
||||
) {
|
||||
let entity = NSEntityDescription.entity(forEntityName: "LoggedUser", in: context)!
|
||||
super.init(entity: entity, insertInto: context)
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.token = token
|
||||
self.username = username
|
||||
}
|
||||
}
|
||||
|
||||
extension LoggedUser: Identifiable {
|
||||
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
import CoreData
|
||||
|
||||
extension Room {
|
||||
var messagesRequest: NSFetchRequest<Message> {
|
||||
let request = Message.fetchRequest()
|
||||
|
||||
request.predicate = NSPredicate(format: "room == %@", self)
|
||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \Message.ts, ascending: true)]
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
var lastMessage: Message? {
|
||||
let request = Message.fetchRequest()
|
||||
|
||||
let thisRoomPredicate = NSPredicate(format: "room == %@", self)
|
||||
let nonInfoMessagePredicate = NSPredicate(format: "t == nil", self)
|
||||
request.predicate = NSCompoundPredicate(
|
||||
andPredicateWithSubpredicates: [thisRoomPredicate, nonInfoMessagePredicate]
|
||||
)
|
||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \Message.ts, ascending: false)]
|
||||
request.fetchLimit = 1
|
||||
|
||||
return try? managedObjectContext?.fetch(request).first
|
||||
}
|
||||
|
||||
var firstMessage: Message? {
|
||||
let request = Message.fetchRequest()
|
||||
|
||||
request.predicate = NSPredicate(format: "room == %@", self)
|
||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \Message.ts, ascending: true)]
|
||||
request.fetchLimit = 1
|
||||
|
||||
return try? managedObjectContext?.fetch(request).first
|
||||
}
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
import CoreData
|
||||
|
||||
@objc
|
||||
public final class Server: NSManagedObject {
|
||||
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Server> {
|
||||
NSFetchRequest<Server>(entityName: "Server")
|
||||
}
|
||||
|
||||
@NSManaged public var iconURL: URL
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var updatedSince: Date?
|
||||
@NSManaged public var url: URL
|
||||
@NSManaged public var useRealName: Bool
|
||||
@NSManaged public var loggedUser: LoggedUser
|
||||
@NSManaged public var certificate: Data?
|
||||
@NSManaged public var password: String?
|
||||
@NSManaged public var version: String
|
||||
|
||||
lazy var database: Database = RocketChatDatabase(server: self)
|
||||
|
||||
@available(*, unavailable)
|
||||
init() {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
init(context: NSManagedObjectContext) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
public override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?) {
|
||||
super.init(entity: entity, insertInto: context)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public init(
|
||||
context: NSManagedObjectContext,
|
||||
iconURL: URL,
|
||||
name: String,
|
||||
updatedSince: Date? = nil,
|
||||
url: URL,
|
||||
useRealName: Bool,
|
||||
loggedUser: LoggedUser,
|
||||
certificate: Data? = nil,
|
||||
password: String? = nil,
|
||||
version: String
|
||||
) {
|
||||
let entity = NSEntityDescription.entity(forEntityName: "Server", in: context)!
|
||||
super.init(entity: entity, insertInto: context)
|
||||
self.iconURL = iconURL
|
||||
self.name = name
|
||||
self.updatedSince = updatedSince
|
||||
self.url = url
|
||||
self.useRealName = useRealName
|
||||
self.loggedUser = loggedUser
|
||||
self.certificate = certificate
|
||||
self.password = password
|
||||
self.version = version
|
||||
}
|
||||
}
|
||||
|
||||
extension Server: Identifiable {
|
||||
|
||||
}
|
||||
|
||||
extension Server {
|
||||
var roomsRequest: NSFetchRequest<Room> {
|
||||
let request = Room.fetchRequest()
|
||||
|
||||
let nonArchived = NSPredicate(format: "archived == false")
|
||||
let open = NSPredicate(format: "open == true")
|
||||
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [nonArchived, open])
|
||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \Room.ts, ascending: false)]
|
||||
|
||||
return request
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
import CoreData
|
||||
|
||||
final class AttachmentModel {
|
||||
private let context: NSManagedObjectContext
|
||||
|
||||
init(context: NSManagedObjectContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func upsert(_ newAttachment: MergedRoom.Message.Attachment) -> Attachment? {
|
||||
let identifier = newAttachment.imageURL ?? newAttachment.audioURL
|
||||
|
||||
guard let identifier = identifier?.absoluteString ?? newAttachment.title else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let attachment = attachment(id: identifier, in: context)
|
||||
|
||||
attachment.imageURL = newAttachment.imageURL
|
||||
attachment.msg = newAttachment.description
|
||||
attachment.width = newAttachment.dimensions?.width ?? 0
|
||||
attachment.height = newAttachment.dimensions?.height ?? 0
|
||||
|
||||
return attachment
|
||||
}
|
||||
}
|
||||
|
||||
extension AttachmentModel {
|
||||
private func attachment(id: String, in context: NSManagedObjectContext) -> Attachment {
|
||||
let request = Attachment.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id == %@", id)
|
||||
|
||||
guard let attachment = try? context.fetch(request).first else {
|
||||
let attachment = Attachment(context: context)
|
||||
attachment.id = id
|
||||
|
||||
return attachment
|
||||
}
|
||||
|
||||
return attachment
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
import CoreData
|
||||
|
||||
final class MessageModel {
|
||||
private let context: NSManagedObjectContext
|
||||
|
||||
init(context: NSManagedObjectContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func upsert(_ newMessage: MergedRoom.Message) -> Message {
|
||||
let attachmentDatabase = AttachmentModel(context: context)
|
||||
let userDatabase = UserModel(context: context)
|
||||
|
||||
let user = userDatabase.upsert(newMessage.u)
|
||||
let message = message(id: newMessage._id, in: context)
|
||||
|
||||
message.status = "received"
|
||||
message.id = newMessage._id
|
||||
message.msg = newMessage.msg
|
||||
message.ts = newMessage.ts
|
||||
message.t = newMessage.t
|
||||
message.groupable = newMessage.groupable ?? true
|
||||
message.editedAt = newMessage.editedAt
|
||||
message.role = newMessage.role
|
||||
message.comment = newMessage.comment
|
||||
message.user = user
|
||||
|
||||
if let messageAttachments = newMessage.attachments {
|
||||
for newAttachment in messageAttachments {
|
||||
let attachment = attachmentDatabase.upsert(newAttachment)
|
||||
|
||||
attachment?.message = message
|
||||
}
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
func fetch(id: String) -> Message? {
|
||||
let request = Message.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id == %@", id)
|
||||
|
||||
return try? context.fetch(request).first
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageModel {
|
||||
private func message(id: String, in context: NSManagedObjectContext) -> Message {
|
||||
let request = Message.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id == %@", id)
|
||||
|
||||
guard let message = try? context.fetch(request).first else {
|
||||
let message = Message(context: context)
|
||||
message.id = id
|
||||
message.ts = Date()
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
}
|