Merge branch 'develop' into feat.block-room-content-e2ee
|
@ -19,13 +19,13 @@ module.exports = {
|
||||||
type: 'ios.app',
|
type: 'ios.app',
|
||||||
binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/Rocket.Chat Experimental.app',
|
binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/Rocket.Chat Experimental.app',
|
||||||
build:
|
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': {
|
'ios.release': {
|
||||||
type: 'ios.app',
|
type: 'ios.app',
|
||||||
binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/Rocket.Chat Experimental.app',
|
binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/Rocket.Chat Experimental.app',
|
||||||
build:
|
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': {
|
'android.debug': {
|
||||||
type: 'android.apk',
|
type: 'android.apk',
|
||||||
|
|
|
@ -147,7 +147,7 @@ android {
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode VERSIONCODE as Integer
|
versionCode VERSIONCODE as Integer
|
||||||
versionName "4.48.0"
|
versionName "4.49.0"
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
if (!isFoss) {
|
if (!isFoss) {
|
||||||
manifestPlaceholders = [BugsnagAPIKey: BugsnagAPIKey as String]
|
manifestPlaceholders = [BugsnagAPIKey: BugsnagAPIKey as String]
|
||||||
|
|
|
@ -114,7 +114,7 @@ export const ImageViewer = ({ uri = '', imageComponentType, width, height, ...pr
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.flex, { width, height, backgroundColor: colors.surfaceRoom }]}>
|
<View style={[styles.flex, { width, height, backgroundColor: colors.surfaceNeutral }]}>
|
||||||
<GestureDetector gesture={gesture}>
|
<GestureDetector gesture={gesture}>
|
||||||
<Animated.View onLayout={onLayout} style={[styles.flex, style]}>
|
<Animated.View onLayout={onLayout} style={[styles.flex, style]}>
|
||||||
<Component
|
<Component
|
||||||
|
|
|
@ -131,7 +131,7 @@ const MessageActions = React.memo(
|
||||||
}
|
}
|
||||||
const editOwn = isOwn(message);
|
const editOwn = isOwn(message);
|
||||||
|
|
||||||
if (!(permissions.hasEditPermission || (Message_AllowEditing && editOwn))) {
|
if (!(permissions.hasEditPermission || (Message_AllowEditing !== false && editOwn))) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const blockEditInMinutes = Message_AllowEditing_BlockEditInMinutes;
|
const blockEditInMinutes = Message_AllowEditing_BlockEditInMinutes;
|
||||||
|
|
|
@ -95,21 +95,16 @@ export const RecordAudio = (): ReactElement | null => {
|
||||||
try {
|
try {
|
||||||
if (!rid) return;
|
if (!rid) return;
|
||||||
setRecordingAudio(false);
|
setRecordingAudio(false);
|
||||||
const fileURI = recordingRef.current?.getURI() as string;
|
const fileURI = recordingRef.current?.getURI();
|
||||||
const fileData = await getInfoAsync(fileURI);
|
const fileData = await getInfoAsync(fileURI as string);
|
||||||
|
const fileInfo = {
|
||||||
if (!fileData.exists) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileInfo: IUpload = {
|
|
||||||
rid,
|
|
||||||
name: `${Date.now()}${RECORDING_EXTENSION}`,
|
name: `${Date.now()}${RECORDING_EXTENSION}`,
|
||||||
|
mime: 'audio/aac',
|
||||||
type: 'audio/aac',
|
type: 'audio/aac',
|
||||||
store: 'Uploads',
|
store: 'Uploads',
|
||||||
path: fileURI,
|
path: fileURI,
|
||||||
size: fileData.size
|
size: fileData.exists ? fileData.size : null
|
||||||
};
|
} as IUpload;
|
||||||
|
|
||||||
if (fileInfo) {
|
if (fileInfo) {
|
||||||
if (permissionToUpload) {
|
if (permissionToUpload) {
|
||||||
|
|
|
@ -1,24 +1,28 @@
|
||||||
|
import { useRoute } from '@react-navigation/native';
|
||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import { saveDraftMessage } from '../../../lib/methods/draftMessage';
|
||||||
import { useRoomContext } from '../../../views/RoomView/context';
|
import { useRoomContext } from '../../../views/RoomView/context';
|
||||||
import { useFocused } from '../context';
|
import { useFocused } from '../context';
|
||||||
import { saveDraftMessage } from '../../../lib/methods/draftMessage';
|
|
||||||
|
|
||||||
export const useAutoSaveDraft = (text = '') => {
|
export const useAutoSaveDraft = (text = '') => {
|
||||||
|
const route = useRoute();
|
||||||
const { rid, tmid, action, selectedMessages } = useRoomContext();
|
const { rid, tmid, action, selectedMessages } = useRoomContext();
|
||||||
const focused = useFocused();
|
const focused = useFocused();
|
||||||
const oldText = useRef('');
|
const oldText = useRef('');
|
||||||
const intervalRef = useRef();
|
const intervalRef = useRef();
|
||||||
|
|
||||||
const saveMessageDraft = useCallback(() => {
|
const mounted = useRef(true);
|
||||||
if (action === 'edit') return;
|
|
||||||
|
|
||||||
|
const saveMessageDraft = useCallback(() => {
|
||||||
|
if (route.name === 'ShareView') return;
|
||||||
|
if (action === 'edit') return;
|
||||||
const draftMessage = selectedMessages?.length ? JSON.stringify({ quotes: selectedMessages, msg: text }) : text;
|
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;
|
oldText.current = draftMessage;
|
||||||
saveDraftMessage({ rid, tmid, draftMessage });
|
saveDraftMessage({ rid, tmid, draftMessage });
|
||||||
}
|
}
|
||||||
}, [action, rid, tmid, text, selectedMessages?.length]);
|
}, [action, rid, tmid, text, selectedMessages?.length, route.name]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focused) {
|
if (focused) {
|
||||||
|
@ -29,7 +33,23 @@ export const useAutoSaveDraft = (text = '') => {
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(intervalRef.current);
|
clearInterval(intervalRef.current);
|
||||||
saveMessageDraft();
|
|
||||||
};
|
};
|
||||||
}, [focused, saveMessageDraft]);
|
}, [focused, saveMessageDraft]);
|
||||||
|
|
||||||
|
// hack to call saveMessageDraft when component is unmounted
|
||||||
|
useEffect(() => {
|
||||||
|
() => {};
|
||||||
|
return () => {
|
||||||
|
mounted.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (!mounted.current) {
|
||||||
|
saveMessageDraft();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[saveMessageDraft]
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -6,7 +6,7 @@ import sharedStyles from '../../../../views/Styles';
|
||||||
export default function useStyle() {
|
export default function useStyle() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const style = StyleSheet.create({
|
const style = StyleSheet.create({
|
||||||
container: { height: 108, flex: 1, borderWidth: 1, borderRadius: 4, marginTop: 8, borderColor: colors.surfaceHover },
|
container: { height: 108, flex: 1, borderWidth: 1, borderRadius: 4, marginTop: 8, borderColor: colors.surfaceNeutral },
|
||||||
callInfoContainer: { flex: 1, alignItems: 'center', paddingLeft: 16, flexDirection: 'row' },
|
callInfoContainer: { flex: 1, alignItems: 'center', paddingLeft: 16, flexDirection: 'row' },
|
||||||
infoContainerText: {
|
infoContainerText: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
|
@ -23,7 +23,7 @@ export default function useStyle() {
|
||||||
},
|
},
|
||||||
callToActionContainer: {
|
callToActionContainer: {
|
||||||
height: 48,
|
height: 48,
|
||||||
backgroundColor: colors.surfaceHover,
|
backgroundColor: colors.surfaceNeutral,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingLeft: 16
|
paddingLeft: 16
|
||||||
|
|
|
@ -55,14 +55,14 @@ const AttachedActions = ({ attachment, getCustomEmoji }: { attachment: IAttachme
|
||||||
|
|
||||||
const Attachments: React.FC<IMessageAttachments> = React.memo(
|
const Attachments: React.FC<IMessageAttachments> = React.memo(
|
||||||
({ attachments, timeFormat, showAttachment, style, getCustomEmoji, isReply, author }: IMessageAttachments) => {
|
({ attachments, timeFormat, showAttachment, style, getCustomEmoji, isReply, author }: IMessageAttachments) => {
|
||||||
const { translateLanguage, isEncrypted } = useContext(MessageContext);
|
const { translateLanguage } = useContext(MessageContext);
|
||||||
|
|
||||||
if (!attachments || attachments.length === 0) {
|
if (!attachments || attachments.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachmentsElements = attachments.map((file: IAttachment, index: number) => {
|
const attachmentsElements = attachments.map((file: IAttachment, index: number) => {
|
||||||
const msg = isEncrypted ? '' : getMessageFromAttachment(file, translateLanguage);
|
const msg = getMessageFromAttachment(file, translateLanguage);
|
||||||
if (file && file.image_url) {
|
if (file && file.image_url) {
|
||||||
return (
|
return (
|
||||||
<Image
|
<Image
|
||||||
|
|
|
@ -19,7 +19,7 @@ const OverlayComponent = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<View style={[style, styles.blurView, { backgroundColor: colors.surfaceDark }]} />
|
<View style={[style, styles.blurView, { backgroundColor: colors.surfaceNeutral }]} />
|
||||||
<View style={[style, styles.blurIndicator]}>
|
<View style={[style, styles.blurIndicator]}>
|
||||||
{loading ? <RCActivityIndicator size={54} /> : <CustomIcon name={iconName} size={54} />}
|
{loading ? <RCActivityIndicator size={54} /> : <CustomIcon name={iconName} size={54} />}
|
||||||
</View>
|
</View>
|
||||||
|
|
|
@ -428,8 +428,7 @@ class MessageContainer extends React.Component<IMessageContainerProps, IMessageC
|
||||||
threadBadgeColor,
|
threadBadgeColor,
|
||||||
toggleFollowThread,
|
toggleFollowThread,
|
||||||
replies,
|
replies,
|
||||||
translateLanguage: canTranslateMessage ? autoTranslateLanguage : undefined,
|
translateLanguage: canTranslateMessage ? autoTranslateLanguage : undefined
|
||||||
isEncrypted: this.isEncrypted
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* @ts-ignore*/}
|
{/* @ts-ignore*/}
|
||||||
|
|
|
@ -103,7 +103,8 @@ export default StyleSheet.create({
|
||||||
minHeight: isTablet ? 300 : 200,
|
minHeight: isTablet ? 300 : 200,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
overflow: 'hidden'
|
overflow: 'hidden',
|
||||||
|
borderColor: 'rgba(0, 0, 0, 0.1)'
|
||||||
},
|
},
|
||||||
imageBlurContainer: {
|
imageBlurContainer: {
|
||||||
height: '100%'
|
height: '100%'
|
||||||
|
|
|
@ -1,10 +1,8 @@
|
||||||
import Model from '@nozbe/watermelondb/Model';
|
import Model from '@nozbe/watermelondb/Model';
|
||||||
|
|
||||||
import { E2EType, MessageType } from './IMessage';
|
|
||||||
|
|
||||||
export interface IUpload {
|
export interface IUpload {
|
||||||
id?: string;
|
id?: string;
|
||||||
rid: string;
|
rid?: string;
|
||||||
path: string;
|
path: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
tmid?: string;
|
tmid?: string;
|
||||||
|
@ -16,8 +14,6 @@ export interface IUpload {
|
||||||
error?: boolean;
|
error?: boolean;
|
||||||
subscription?: { id: string };
|
subscription?: { id: string };
|
||||||
msg?: string;
|
msg?: string;
|
||||||
t?: MessageType;
|
|
||||||
e2e?: E2EType;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TUploadModel = IUpload & Model;
|
export type TUploadModel = IUpload & Model;
|
||||||
|
|
|
@ -11,15 +11,7 @@ import log from '../methods/helpers/log';
|
||||||
import { store } from '../store/auxStore';
|
import { store } from '../store/auxStore';
|
||||||
import { joinVectorData, randomPassword, splitVectorData, toString, utf8ToBuffer } from './utils';
|
import { joinVectorData, randomPassword, splitVectorData, toString, utf8ToBuffer } from './utils';
|
||||||
import EncryptionRoom from './room';
|
import EncryptionRoom from './room';
|
||||||
import {
|
import { IMessage, ISubscription, TMessageModel, TSubscriptionModel, TThreadMessageModel, TThreadModel } from '../../definitions';
|
||||||
IMessage,
|
|
||||||
ISubscription,
|
|
||||||
IUpload,
|
|
||||||
TMessageModel,
|
|
||||||
TSubscriptionModel,
|
|
||||||
TThreadMessageModel,
|
|
||||||
TThreadModel
|
|
||||||
} from '../../definitions';
|
|
||||||
import {
|
import {
|
||||||
E2E_BANNER_TYPE,
|
E2E_BANNER_TYPE,
|
||||||
E2E_MESSAGE_TYPE,
|
E2E_MESSAGE_TYPE,
|
||||||
|
@ -42,7 +34,6 @@ class Encryption {
|
||||||
handshake: Function;
|
handshake: Function;
|
||||||
decrypt: Function;
|
decrypt: Function;
|
||||||
encrypt: Function;
|
encrypt: Function;
|
||||||
encryptUpload: Function;
|
|
||||||
importRoomKey: Function;
|
importRoomKey: Function;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -284,7 +275,7 @@ class Encryption {
|
||||||
];
|
];
|
||||||
toDecrypt = (await Promise.all(
|
toDecrypt = (await Promise.all(
|
||||||
toDecrypt.map(async message => {
|
toDecrypt.map(async message => {
|
||||||
const { t, msg, tmsg, attachments } = message;
|
const { t, msg, tmsg } = message;
|
||||||
let newMessage: TMessageModel = {} as TMessageModel;
|
let newMessage: TMessageModel = {} as TMessageModel;
|
||||||
if (message.subscription) {
|
if (message.subscription) {
|
||||||
const { id: rid } = message.subscription;
|
const { id: rid } = message.subscription;
|
||||||
|
@ -293,8 +284,7 @@ class Encryption {
|
||||||
t,
|
t,
|
||||||
rid,
|
rid,
|
||||||
msg: msg as string,
|
msg: msg as string,
|
||||||
tmsg,
|
tmsg
|
||||||
attachments
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -447,7 +437,7 @@ class Encryption {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Encrypt a message
|
// Encrypt a message
|
||||||
encryptMessage = async (message: IMessage | IUpload) => {
|
encryptMessage = async (message: IMessage) => {
|
||||||
const { rid } = message;
|
const { rid } = message;
|
||||||
const db = database.active;
|
const db = database.active;
|
||||||
const subCollection = db.get('subscriptions');
|
const subCollection = db.get('subscriptions');
|
||||||
|
@ -469,10 +459,6 @@ class Encryption {
|
||||||
}
|
}
|
||||||
|
|
||||||
const roomE2E = await this.getRoomInstance(rid);
|
const roomE2E = await this.getRoomInstance(rid);
|
||||||
|
|
||||||
if ('path' in message) {
|
|
||||||
return roomE2E.encryptUpload(message);
|
|
||||||
}
|
|
||||||
return roomE2E.encrypt(message);
|
return roomE2E.encrypt(message);
|
||||||
} catch {
|
} catch {
|
||||||
// Subscription not found
|
// Subscription not found
|
||||||
|
@ -484,7 +470,7 @@ class Encryption {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Decrypt a message
|
// 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;
|
const { t, e2e } = message;
|
||||||
|
|
||||||
// Prevent create a new instance if this room was encrypted sometime ago
|
// 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 parse from 'url-parse';
|
||||||
|
|
||||||
import getSingleMessage from '../methods/getSingleMessage';
|
import getSingleMessage from '../methods/getSingleMessage';
|
||||||
import { IMessage, IUpload, IUser } from '../../definitions';
|
import { IMessage, IUser } from '../../definitions';
|
||||||
import Deferred from './helpers/deferred';
|
import Deferred from './helpers/deferred';
|
||||||
import { debounce } from '../methods/helpers';
|
import { debounce } from '../methods/helpers';
|
||||||
import database from '../database';
|
import database from '../database';
|
||||||
|
@ -247,38 +247,8 @@ export default class EncryptionRoom {
|
||||||
return message;
|
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
|
// Decrypt text
|
||||||
decryptText = async (msg: string | ArrayBuffer) => {
|
decryptText = async (msg: string | ArrayBuffer) => {
|
||||||
if (!msg) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
msg = b64ToBuffer(msg.slice(12) as string);
|
msg = b64ToBuffer(msg.slice(12) as string);
|
||||||
const [vector, cipherText] = splitVectorData(msg);
|
const [vector, cipherText] = splitVectorData(msg);
|
||||||
|
|
||||||
|
@ -309,10 +279,6 @@ export default class EncryptionRoom {
|
||||||
tmsg = await this.decryptText(tmsg);
|
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 = {
|
const decryptedMessage: IMessage = {
|
||||||
...message,
|
...message,
|
||||||
tmsg,
|
tmsg,
|
||||||
|
|
|
@ -18,4 +18,5 @@ export type TKeyEmitterEvent = keyof TEmitterEvents;
|
||||||
|
|
||||||
export const emitter = mitt<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));
|
||||||
|
|
|
@ -67,6 +67,7 @@ const RCSSLPinning = Platform.select({
|
||||||
certificate = persistCertificate(name, certificate.password);
|
certificate = persistCertificate(name, certificate.password);
|
||||||
}
|
}
|
||||||
UserPreferences.setMap(extractHostname(server), certificate);
|
UserPreferences.setMap(extractHostname(server), certificate);
|
||||||
|
SSLPinning?.setCertificate(server, certificate.path, certificate.password);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -4,14 +4,12 @@ import isEmpty from 'lodash/isEmpty';
|
||||||
import { FetchBlobResponse, StatefulPromise } from 'rn-fetch-blob';
|
import { FetchBlobResponse, StatefulPromise } from 'rn-fetch-blob';
|
||||||
import { Alert } from 'react-native';
|
import { Alert } from 'react-native';
|
||||||
|
|
||||||
import { Encryption } from '../encryption';
|
|
||||||
import { IUpload, IUser, TUploadModel } from '../../definitions';
|
import { IUpload, IUser, TUploadModel } from '../../definitions';
|
||||||
import i18n from '../../i18n';
|
import i18n from '../../i18n';
|
||||||
import database from '../database';
|
import database from '../database';
|
||||||
import FileUpload from './helpers/fileUpload';
|
import FileUpload from './helpers/fileUpload';
|
||||||
import { IFileUpload } from './helpers/fileUpload/interfaces';
|
import { IFileUpload } from './helpers/fileUpload/interfaces';
|
||||||
import log from './helpers/log';
|
import log from './helpers/log';
|
||||||
import { E2E_MESSAGE_TYPE } from '../constants';
|
|
||||||
|
|
||||||
const uploadQueue: { [index: string]: StatefulPromise<FetchBlobResponse> } = {};
|
const uploadQueue: { [index: string]: StatefulPromise<FetchBlobResponse> } = {};
|
||||||
|
|
||||||
|
@ -87,8 +85,6 @@ export function sendFileMessage(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const encryptedFileInfo = await Encryption.encryptMessage(fileInfo);
|
|
||||||
|
|
||||||
const formData: IFileUpload[] = [];
|
const formData: IFileUpload[] = [];
|
||||||
formData.push({
|
formData.push({
|
||||||
name: 'file',
|
name: 'file',
|
||||||
|
@ -100,7 +96,7 @@ export function sendFileMessage(
|
||||||
if (fileInfo.description) {
|
if (fileInfo.description) {
|
||||||
formData.push({
|
formData.push({
|
||||||
name: 'description',
|
name: 'description',
|
||||||
data: encryptedFileInfo.description
|
data: fileInfo.description
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -118,17 +114,6 @@ export function sendFileMessage(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (encryptedFileInfo.t === E2E_MESSAGE_TYPE) {
|
|
||||||
formData.push({
|
|
||||||
name: 't',
|
|
||||||
data: encryptedFileInfo.t
|
|
||||||
});
|
|
||||||
formData.push({
|
|
||||||
name: 'e2e',
|
|
||||||
data: encryptedFileInfo.e2e
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
...RocketChatSettings.customHeaders,
|
...RocketChatSettings.customHeaders,
|
||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
|
|
|
@ -8,7 +8,6 @@ import { Encryption } from '../encryption';
|
||||||
import { E2EType, IMessage, IUser, TMessageModel } from '../../definitions';
|
import { E2EType, IMessage, IUser, TMessageModel } from '../../definitions';
|
||||||
import sdk from '../services/sdk';
|
import sdk from '../services/sdk';
|
||||||
import { E2E_MESSAGE_TYPE, E2E_STATUS, messagesStatus } from '../constants';
|
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 changeMessageStatus = async (id: string, status: number, tmid?: string, message?: IMessage) => {
|
||||||
const db = database.active;
|
const db = database.active;
|
||||||
|
@ -232,9 +231,6 @@ export async function sendMessage(
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendMessageCall(message);
|
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) {
|
} catch (e) {
|
||||||
log(e);
|
log(e);
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@ import protectedFunction from '../methods/helpers/protectedFunction';
|
||||||
import database from '../database';
|
import database from '../database';
|
||||||
import { twoFactor } from './twoFactor';
|
import { twoFactor } from './twoFactor';
|
||||||
import { store } from '../store/auxStore';
|
import { store } from '../store/auxStore';
|
||||||
import { loginRequest, setLoginServices, setUser } from '../../actions/login';
|
import { loginRequest, logout, setLoginServices, setUser } from '../../actions/login';
|
||||||
import sdk from './sdk';
|
import sdk from './sdk';
|
||||||
import I18n from '../../i18n';
|
import I18n from '../../i18n';
|
||||||
import { ICredentials, ILoggedUser, STATUSES } from '../../definitions';
|
import { ICredentials, ILoggedUser, STATUSES } from '../../definitions';
|
||||||
|
@ -46,6 +46,7 @@ let usersListener: any;
|
||||||
let notifyAllListener: any;
|
let notifyAllListener: any;
|
||||||
let rolesListener: any;
|
let rolesListener: any;
|
||||||
let notifyLoggedListener: any;
|
let notifyLoggedListener: any;
|
||||||
|
let logoutListener: any;
|
||||||
|
|
||||||
function connect({ server, logoutOnError = false }: { server: string; logoutOnError?: boolean }): Promise<void> {
|
function connect({ server, logoutOnError = false }: { server: string; logoutOnError?: boolean }): Promise<void> {
|
||||||
return new Promise<void>(resolve => {
|
return new Promise<void>(resolve => {
|
||||||
|
@ -89,6 +90,10 @@ function connect({ server, logoutOnError = false }: { server: string; logoutOnEr
|
||||||
notifyLoggedListener.then(stopListener);
|
notifyLoggedListener.then(stopListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (logoutListener) {
|
||||||
|
logoutListener.then(stopListener);
|
||||||
|
}
|
||||||
|
|
||||||
unsubscribeRooms();
|
unsubscribeRooms();
|
||||||
|
|
||||||
EventEmitter.emit('INQUIRY_UNSUBSCRIBE');
|
EventEmitter.emit('INQUIRY_UNSUBSCRIBE');
|
||||||
|
@ -270,6 +275,8 @@ function connect({ server, logoutOnError = false }: { server: string; logoutOnEr
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
logoutListener = sdk.current.onStreamData('stream-force_logout', () => store.dispatch(logout(true)));
|
||||||
|
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -121,19 +121,21 @@ const AttachmentView = (): React.ReactElement => {
|
||||||
const options: StackNavigationOptions = {
|
const options: StackNavigationOptions = {
|
||||||
title: title || '',
|
title: title || '',
|
||||||
headerTitleAlign: 'center',
|
headerTitleAlign: 'center',
|
||||||
headerTitleStyle: { color: colors.surfaceTint },
|
headerTitleStyle: { color: colors.fontDefault },
|
||||||
headerTintColor: colors.surfaceTint,
|
headerTintColor: colors.surfaceTint,
|
||||||
headerTitleContainerStyle: { flex: 1, maxWidth: undefined },
|
headerTitleContainerStyle: { flex: 1, maxWidth: undefined },
|
||||||
headerLeftContainerStyle: { flexGrow: undefined, flexBasis: undefined },
|
headerLeftContainerStyle: { flexGrow: undefined, flexBasis: undefined },
|
||||||
headerRightContainerStyle: { flexGrow: undefined, flexBasis: undefined },
|
headerRightContainerStyle: { flexGrow: undefined, flexBasis: undefined },
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<HeaderButton.CloseModal testID='close-attachment-view' navigation={navigation} color={colors.surfaceTint} />
|
<HeaderButton.CloseModal testID='close-attachment-view' navigation={navigation} color={colors.fontDefault} />
|
||||||
),
|
),
|
||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
Allow_Save_Media_to_Gallery && !isImageBase64(attachment.image_url) ? (
|
Allow_Save_Media_to_Gallery && !isImageBase64(attachment.image_url) ? (
|
||||||
<HeaderButton.Download testID='save-image' onPress={handleSave} color={colors.surfaceTint} />
|
<HeaderButton.Download testID='save-image' onPress={handleSave} color={colors.fontDefault} />
|
||||||
) : null,
|
) : null,
|
||||||
headerBackground: () => <HeaderBackground style={{ backgroundColor: colors.surfaceDark, shadowOpacity: 0, elevation: 0 }} />
|
headerBackground: () => (
|
||||||
|
<HeaderBackground style={{ backgroundColor: colors.surfaceNeutral, shadowOpacity: 0, elevation: 0 }} />
|
||||||
|
)
|
||||||
};
|
};
|
||||||
navigation.setOptions(options);
|
navigation.setOptions(options);
|
||||||
};
|
};
|
||||||
|
|
|
@ -45,7 +45,7 @@ describe('SwitchItemEncrypted', () => {
|
||||||
const component = screen.queryByTestId(testEncrypted.testSwitchID);
|
const component = screen.queryByTestId(testEncrypted.testSwitchID);
|
||||||
expect(component).toBeTruthy();
|
expect(component).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should change value of switch', () => {
|
it('should change value of switch', () => {
|
||||||
render(
|
render(
|
||||||
<SwitchItemEncrypted
|
<SwitchItemEncrypted
|
||||||
|
@ -62,7 +62,7 @@ describe('SwitchItemEncrypted', () => {
|
||||||
expect(onPressMock).toHaveReturnedWith({ value: !testEncrypted.encrypted });
|
expect(onPressMock).toHaveReturnedWith({ value: !testEncrypted.encrypted });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('label when encrypted and isTeam are false and is a public channel', () => {
|
it('label when encrypted and isTeam are false and is a public channel', () => {
|
||||||
render(
|
render(
|
||||||
<SwitchItemEncrypted
|
<SwitchItemEncrypted
|
||||||
|
@ -76,7 +76,7 @@ describe('SwitchItemEncrypted', () => {
|
||||||
const component = screen.queryByTestId(testEncrypted.testLabelID);
|
const component = screen.queryByTestId(testEncrypted.testLabelID);
|
||||||
expect(component?.props.children).toBe(i18n.t('Channel_hint_encrypted_not_available'));
|
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', () => {
|
it('label when encrypted and isTeam are true and is a private team', () => {
|
||||||
testEncrypted.isTeam = true;
|
testEncrypted.isTeam = true;
|
||||||
testEncrypted.type = true;
|
testEncrypted.type = true;
|
||||||
|
|
|
@ -1006,7 +1006,9 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
|
||||||
const { rid } = this.state.room;
|
const { rid } = this.state.room;
|
||||||
const { user } = this.props;
|
const { user } = this.props;
|
||||||
sendMessage(rid, message, this.tmid, user, tshow).then(() => {
|
sendMessage(rid, message, this.tmid, user, tshow).then(() => {
|
||||||
this.setLastOpen(null);
|
if (this.mounted) {
|
||||||
|
this.setLastOpen(null);
|
||||||
|
}
|
||||||
Review.pushPositiveEvent();
|
Review.pushPositiveEvent();
|
||||||
});
|
});
|
||||||
this.resetAction();
|
this.resetAction();
|
||||||
|
|
|
@ -68,7 +68,7 @@ const Header = React.memo(({ room, thread }: IHeader) => {
|
||||||
icon = 'channel-private';
|
icon = 'channel-private';
|
||||||
}
|
}
|
||||||
|
|
||||||
const textColor = themes[theme].surfaceTint;
|
const textColor = themes[theme].fontDefault;
|
||||||
|
|
||||||
let title;
|
let title;
|
||||||
if (thread?.id) {
|
if (thread?.id) {
|
||||||
|
|
|
@ -48,7 +48,7 @@ interface IIconPreview {
|
||||||
|
|
||||||
const IconPreview = React.memo(({ iconName, title, description, theme, width, height, danger }: IIconPreview) => (
|
const IconPreview = React.memo(({ iconName, title, description, theme, width, height, danger }: IIconPreview) => (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={{ backgroundColor: themes[theme].surfaceRoom }}
|
style={{ backgroundColor: themes[theme].surfaceNeutral }}
|
||||||
contentContainerStyle={[styles.fileContainer, { width, height }]}
|
contentContainerStyle={[styles.fileContainer, { width, height }]}
|
||||||
>
|
>
|
||||||
<CustomIcon
|
<CustomIcon
|
||||||
|
|
|
@ -121,25 +121,25 @@ class ShareView extends Component<IShareViewProps, IShareViewState> {
|
||||||
const options: StackNavigationOptions = {
|
const options: StackNavigationOptions = {
|
||||||
headerTitle: () => <Header room={room} thread={thread} />,
|
headerTitle: () => <Header room={room} thread={thread} />,
|
||||||
headerTitleAlign: 'left',
|
headerTitleAlign: 'left',
|
||||||
headerTintColor: themes[theme].surfaceTint
|
headerTintColor: themes[theme].backdropColor
|
||||||
};
|
};
|
||||||
|
|
||||||
// if is share extension show default back button
|
// if is share extension show default back button
|
||||||
if (!this.isShareExtension) {
|
if (!this.isShareExtension) {
|
||||||
options.headerLeft = () => (
|
options.headerLeft = () => (
|
||||||
<HeaderButton.CloseModal navigation={navigation} color={themes[theme].surfaceTint} testID='share-view-close' />
|
<HeaderButton.CloseModal navigation={navigation} color={themes[theme].fontDefault} testID='share-view-close' />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!attachments.length && !readOnly) {
|
if (!attachments.length && !readOnly) {
|
||||||
options.headerRight = () => (
|
options.headerRight = () => (
|
||||||
<HeaderButton.Container>
|
<HeaderButton.Container>
|
||||||
<HeaderButton.Item title={I18n.t('Send')} onPress={this.send} color={themes[theme].surfaceTint} />
|
<HeaderButton.Item title={I18n.t('Send')} onPress={this.send} color={themes[theme].fontDefault} />
|
||||||
</HeaderButton.Container>
|
</HeaderButton.Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
options.headerBackground = () => <View style={[styles.container, { backgroundColor: themes[theme].surfaceDark }]} />;
|
options.headerBackground = () => <View style={[styles.container, { backgroundColor: themes[theme].surfaceNeutral }]} />;
|
||||||
|
|
||||||
navigation.setOptions(options);
|
navigation.setOptions(options);
|
||||||
};
|
};
|
||||||
|
@ -257,7 +257,6 @@ class ShareView extends Component<IShareViewProps, IShareViewState> {
|
||||||
return sendFileMessage(
|
return sendFileMessage(
|
||||||
room.rid,
|
room.rid,
|
||||||
{
|
{
|
||||||
rid: room.rid,
|
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
size,
|
size,
|
||||||
|
@ -351,8 +350,7 @@ class ShareView extends Component<IShareViewProps, IShareViewState> {
|
||||||
selectedMessages,
|
selectedMessages,
|
||||||
onSendMessage: this.send,
|
onSendMessage: this.send,
|
||||||
onRemoveQuoteMessage: this.onRemoveQuoteMessage
|
onRemoveQuoteMessage: this.onRemoveQuoteMessage
|
||||||
}}
|
}}>
|
||||||
>
|
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Preview
|
<Preview
|
||||||
// using key just to reset zoom/move after change selected
|
// using key just to reset zoom/move after change selected
|
||||||
|
@ -405,7 +403,7 @@ class ShareView extends Component<IShareViewProps, IShareViewState> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ backgroundColor: themes[theme].surfaceRoom }}>
|
<SafeAreaView style={{ backgroundColor: themes[theme].backdropColor, flex: 1 }}>
|
||||||
<StatusBar barStyle='light-content' backgroundColor={themes[theme].surfaceDark} />
|
<StatusBar barStyle='light-content' backgroundColor={themes[theme].surfaceDark} />
|
||||||
{this.renderContent()}
|
{this.renderContent()}
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
|
After Width: | Height: | Size: 424 KiB |
|
@ -149,6 +149,12 @@
|
||||||
"idiom" : "ios-marketing",
|
"idiom" : "ios-marketing",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "1024 1.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "watchos",
|
||||||
|
"size" : "1024x1024"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
|
|
After Width: | Height: | Size: 56 KiB |
|
@ -149,6 +149,12 @@
|
||||||
"idiom" : "ios-marketing",
|
"idiom" : "ios-marketing",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "1024 1.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "watchos",
|
||||||
|
"size" : "1024x1024"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,89 @@
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
final class RoomModel {
|
||||||
|
private let context: NSManagedObjectContext
|
||||||
|
|
||||||
|
init(context: NSManagedObjectContext) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func upsert(_ newRoom: MergedRoom) -> Room {
|
||||||
|
let room = room(id: newRoom.id, in: context)
|
||||||
|
|
||||||
|
room.name = newRoom.name ?? room.name
|
||||||
|
room.fname = newRoom.fname ?? room.fname
|
||||||
|
room.t = newRoom.t
|
||||||
|
room.unread = Int32(newRoom.unread)
|
||||||
|
room.alert = newRoom.alert
|
||||||
|
room.lr = newRoom.lr ?? room.lr
|
||||||
|
room.open = newRoom.open ?? true
|
||||||
|
room.rid = newRoom.rid
|
||||||
|
room.hideUnreadStatus = newRoom.hideUnreadStatus ?? room.hideUnreadStatus
|
||||||
|
|
||||||
|
room.updatedAt = newRoom.updatedAt ?? room.updatedAt
|
||||||
|
room.usernames = newRoom.usernames ?? room.usernames
|
||||||
|
room.uids = newRoom.uids ?? room.uids
|
||||||
|
room.prid = newRoom.prid ?? room.prid
|
||||||
|
room.isReadOnly = newRoom.isReadOnly ?? room.isReadOnly
|
||||||
|
room.encrypted = newRoom.encrypted ?? room.encrypted
|
||||||
|
room.teamMain = newRoom.teamMain ?? room.teamMain
|
||||||
|
room.archived = newRoom.archived ?? room.archived
|
||||||
|
room.broadcast = newRoom.broadcast ?? room.broadcast
|
||||||
|
room.ts = newRoom.ts ?? room.ts
|
||||||
|
|
||||||
|
let messageDatabase = MessageModel(context: context)
|
||||||
|
|
||||||
|
if let lastMessage = newRoom.lastMessage {
|
||||||
|
let message = messageDatabase.upsert(lastMessage)
|
||||||
|
message.room = room
|
||||||
|
}
|
||||||
|
|
||||||
|
return room
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(_ room: Room) {
|
||||||
|
context.delete(room)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetch(ids: [String]) -> [Room] {
|
||||||
|
let request = Room.fetchRequest()
|
||||||
|
request.predicate = NSPredicate(format: "id IN %@", ids)
|
||||||
|
|
||||||
|
return (try? context.fetch(request)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetch(id: String) -> Room {
|
||||||
|
room(id: id, in: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension RoomModel {
|
||||||
|
private func room(id: String, in context: NSManagedObjectContext) -> Room {
|
||||||
|
let request = Room.fetchRequest()
|
||||||
|
request.predicate = NSPredicate(format: "id == %@", id)
|
||||||
|
|
||||||
|
guard let room = try? context.fetch(request).first else {
|
||||||
|
let room = Room(context: context)
|
||||||
|
room.id = id
|
||||||
|
|
||||||
|
return room
|
||||||
|
}
|
||||||
|
|
||||||
|
return room
|
||||||
|
}
|
||||||
|
|
||||||
|
private func room(rid: String, in context: NSManagedObjectContext) -> Room {
|
||||||
|
let request = Room.fetchRequest()
|
||||||
|
request.predicate = NSPredicate(format: "rid == %@", rid)
|
||||||
|
|
||||||
|
guard let room = try? context.fetch(request).first else {
|
||||||
|
let room = Room(context: context)
|
||||||
|
room.rid = rid
|
||||||
|
|
||||||
|
return room
|
||||||
|
}
|
||||||
|
|
||||||
|
return room
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
final class UserModel {
|
||||||
|
private let context: NSManagedObjectContext
|
||||||
|
|
||||||
|
init(context: NSManagedObjectContext) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsert(_ newUser: MergedRoom.Message.User) -> User {
|
||||||
|
let user = user(id: newUser._id, in: context)
|
||||||
|
user.name = newUser.name
|
||||||
|
user.username = newUser.username
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetch(id: String) -> User {
|
||||||
|
user(id: id, in: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension UserModel {
|
||||||
|
private func user(id: String, in context: NSManagedObjectContext) -> User {
|
||||||
|
let request = User.fetchRequest()
|
||||||
|
request.predicate = NSPredicate(format: "id == %@", id)
|
||||||
|
|
||||||
|
guard let user = try? context.fetch(request).first else {
|
||||||
|
let user = User(context: context)
|
||||||
|
user.id = id
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,78 @@
|
||||||
|
<?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="Attachment" representedClassName="Attachment" syncable="YES" codeGenerationType="class">
|
||||||
|
<attribute name="height" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="id" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="imageURL" optional="YES" attributeType="URI"/>
|
||||||
|
<attribute name="msg" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="width" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||||
|
<relationship name="message" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Message" inverseName="attachments" inverseEntity="Message"/>
|
||||||
|
<uniquenessConstraints>
|
||||||
|
<uniquenessConstraint>
|
||||||
|
<constraint value="id"/>
|
||||||
|
</uniquenessConstraint>
|
||||||
|
</uniquenessConstraints>
|
||||||
|
</entity>
|
||||||
|
<entity name="Message" representedClassName="Message" syncable="YES" codeGenerationType="class">
|
||||||
|
<attribute name="comment" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="editedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="groupable" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="id" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="msg" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="role" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="status" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="t" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="ts" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<relationship name="attachments" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Attachment" inverseName="message" inverseEntity="Attachment"/>
|
||||||
|
<relationship name="room" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Room" inverseName="messages" inverseEntity="Room"/>
|
||||||
|
<relationship name="user" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="User" inverseName="messages" inverseEntity="User"/>
|
||||||
|
<uniquenessConstraints>
|
||||||
|
<uniquenessConstraint>
|
||||||
|
<constraint value="id"/>
|
||||||
|
</uniquenessConstraint>
|
||||||
|
</uniquenessConstraints>
|
||||||
|
</entity>
|
||||||
|
<entity name="Room" representedClassName="Room" syncable="YES" codeGenerationType="class">
|
||||||
|
<attribute name="alert" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="archived" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="broadcast" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="encrypted" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="fname" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="hasMoreMessages" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="hideUnreadStatus" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="id" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="isReadOnly" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="lr" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="name" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="open" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="prid" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="rid" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="synced" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="t" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="teamMain" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="ts" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="uids" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer" customClassName="[String]"/>
|
||||||
|
<attribute name="unread" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="updatedSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="usernames" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer" customClassName="[String]"/>
|
||||||
|
<relationship name="messages" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Message" inverseName="room" inverseEntity="Message"/>
|
||||||
|
<uniquenessConstraints>
|
||||||
|
<uniquenessConstraint>
|
||||||
|
<constraint value="id"/>
|
||||||
|
<constraint value="rid"/>
|
||||||
|
</uniquenessConstraint>
|
||||||
|
</uniquenessConstraints>
|
||||||
|
</entity>
|
||||||
|
<entity name="User" representedClassName="User" syncable="YES" codeGenerationType="class">
|
||||||
|
<attribute name="id" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="name" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="username" optional="YES" attributeType="String"/>
|
||||||
|
<relationship name="messages" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Message" inverseName="user" inverseEntity="Message"/>
|
||||||
|
<uniquenessConstraints>
|
||||||
|
<uniquenessConstraint>
|
||||||
|
<constraint value="id"/>
|
||||||
|
</uniquenessConstraint>
|
||||||
|
</uniquenessConstraints>
|
||||||
|
</entity>
|
||||||
|
</model>
|
|
@ -0,0 +1,316 @@
|
||||||
|
import Combine
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
protocol Database {
|
||||||
|
var viewContext: NSManagedObjectContext { get }
|
||||||
|
func has(context: NSManagedObjectContext) -> Bool
|
||||||
|
|
||||||
|
func room(id: String) -> Room?
|
||||||
|
func room(rid: String) -> Room?
|
||||||
|
func remove(_ message: Message)
|
||||||
|
|
||||||
|
func handleRoomsResponse(_ subscriptionsResponse: SubscriptionsResponse, _ roomsResponse: RoomsResponse)
|
||||||
|
func handleHistoryResponse(_ historyResponse: HistoryResponse, in roomID: String)
|
||||||
|
func handleMessagesResponse(_ messagesResponse: MessagesResponse, in roomID: String, newUpdatedSince: Date)
|
||||||
|
func handleSendMessageResponse(_ sendMessageResponse: SendMessageResponse, in roomID: String)
|
||||||
|
func handleSendMessageRequest(_ newMessage: MergedRoom.Message, in roomID: String)
|
||||||
|
func handleReadResponse(_ readResponse: ReadResponse, in roomID: String)
|
||||||
|
func handleSendMessageError(_ messageID: String)
|
||||||
|
|
||||||
|
func remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
final class RocketChatDatabase: Database {
|
||||||
|
private let server: Server
|
||||||
|
|
||||||
|
init(server: Server) {
|
||||||
|
self.server = server
|
||||||
|
}
|
||||||
|
|
||||||
|
var viewContext: NSManagedObjectContext {
|
||||||
|
container.viewContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func has(context: NSManagedObjectContext) -> Bool {
|
||||||
|
context == backgroundContext
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let model: NSManagedObjectModel = {
|
||||||
|
guard let url = Bundle.main.url(forResource: "RocketChat", withExtension: "momd"),
|
||||||
|
let managedObjectModel = NSManagedObjectModel(contentsOf: url) else {
|
||||||
|
fatalError("Can't find Core Data Model")
|
||||||
|
}
|
||||||
|
|
||||||
|
return managedObjectModel
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var container: NSPersistentContainer = {
|
||||||
|
let name = server.url.host ?? "default"
|
||||||
|
|
||||||
|
let container = NSPersistentContainer(name: name, managedObjectModel: Self.model)
|
||||||
|
|
||||||
|
container.loadPersistentStores { _, error in
|
||||||
|
if let error { fatalError("Can't load persistent stores: \(error)") }
|
||||||
|
}
|
||||||
|
|
||||||
|
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||||
|
container.viewContext.automaticallyMergesChangesFromParent = true
|
||||||
|
|
||||||
|
return container
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var backgroundContext = container.newBackgroundContext()
|
||||||
|
|
||||||
|
func remove(_ message: Message) {
|
||||||
|
viewContext.delete(message)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try viewContext.save()
|
||||||
|
} catch {
|
||||||
|
print(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func room(id: String) -> Room? {
|
||||||
|
let request = Room.fetchRequest()
|
||||||
|
request.predicate = NSPredicate(format: "id == %@", id)
|
||||||
|
|
||||||
|
return try? viewContext.fetch(request).first
|
||||||
|
}
|
||||||
|
|
||||||
|
func room(rid: String) -> Room? {
|
||||||
|
let request = Room.fetchRequest()
|
||||||
|
request.predicate = NSPredicate(format: "rid == %@", rid)
|
||||||
|
|
||||||
|
return try? viewContext.fetch(request).first
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove() {
|
||||||
|
guard let url = container.persistentStoreDescriptions.first?.url else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try container.persistentStoreCoordinator.destroyPersistentStore(at: url, type: .sqlite)
|
||||||
|
} catch {
|
||||||
|
print(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension RocketChatDatabase {
|
||||||
|
func handleReadResponse(_ readResponse: ReadResponse, in roomID: String) {
|
||||||
|
backgroundContext.performBackgroundTask { context in
|
||||||
|
let roomDatabase = RoomModel(context: context)
|
||||||
|
|
||||||
|
let room = roomDatabase.fetch(id: roomID)
|
||||||
|
|
||||||
|
room.alert = false
|
||||||
|
room.unread = 0
|
||||||
|
|
||||||
|
do {
|
||||||
|
try context.save()
|
||||||
|
} catch {
|
||||||
|
print(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSendMessageError(_ messageID: String) {
|
||||||
|
backgroundContext.performBackgroundTask { context in
|
||||||
|
let messageDatabase = MessageModel(context: context)
|
||||||
|
|
||||||
|
if let message = messageDatabase.fetch(id: messageID) {
|
||||||
|
message.status = "error"
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try context.save()
|
||||||
|
} catch {
|
||||||
|
print(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSendMessageRequest(_ newMessage: MergedRoom.Message, in roomID: String) {
|
||||||
|
backgroundContext.performBackgroundTask { context in
|
||||||
|
let roomDatabase = RoomModel(context: context)
|
||||||
|
let messageDatabase = MessageModel(context: context)
|
||||||
|
|
||||||
|
let room = roomDatabase.fetch(id: roomID)
|
||||||
|
|
||||||
|
let message = messageDatabase.upsert(newMessage)
|
||||||
|
|
||||||
|
message.status = "temp"
|
||||||
|
message.room = room
|
||||||
|
|
||||||
|
do {
|
||||||
|
try context.save()
|
||||||
|
} catch {
|
||||||
|
print(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSendMessageResponse(_ sendMessageResponse: SendMessageResponse, in roomID: String) {
|
||||||
|
let message = sendMessageResponse.message
|
||||||
|
|
||||||
|
backgroundContext.performBackgroundTask { context in
|
||||||
|
let messageDatabase = MessageModel(context: context)
|
||||||
|
let roomDatabase = RoomModel(context: context)
|
||||||
|
|
||||||
|
let room = roomDatabase.fetch(id: roomID)
|
||||||
|
|
||||||
|
if let newMessage = MergedRoom.Message(from: message) {
|
||||||
|
let message = messageDatabase.upsert(newMessage)
|
||||||
|
|
||||||
|
message.room = room
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try context.save()
|
||||||
|
} catch {
|
||||||
|
print(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleMessagesResponse(_ messagesResponse: MessagesResponse, in roomID: String, newUpdatedSince: Date) {
|
||||||
|
let messages = messagesResponse.result.updated
|
||||||
|
|
||||||
|
backgroundContext.performBackgroundTask { context in
|
||||||
|
let messageDatabase = MessageModel(context: context)
|
||||||
|
let roomDatabase = RoomModel(context: context)
|
||||||
|
|
||||||
|
let room = roomDatabase.fetch(id: roomID)
|
||||||
|
|
||||||
|
for message in messages {
|
||||||
|
if let newMessage = MergedRoom.Message(from: message) {
|
||||||
|
let message = messageDatabase.upsert(newMessage)
|
||||||
|
|
||||||
|
message.room = room
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
room.updatedSince = newUpdatedSince
|
||||||
|
|
||||||
|
do {
|
||||||
|
try context.save()
|
||||||
|
} catch {
|
||||||
|
print(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleHistoryResponse(_ historyResponse: HistoryResponse, in roomID: String) {
|
||||||
|
let messages = historyResponse.messages
|
||||||
|
|
||||||
|
backgroundContext.performBackgroundTask { context in
|
||||||
|
let messageDatabase = MessageModel(context: context)
|
||||||
|
let roomDatabase = RoomModel(context: context)
|
||||||
|
|
||||||
|
let room = roomDatabase.fetch(id: roomID)
|
||||||
|
|
||||||
|
room.hasMoreMessages = messages.count == HISTORY_MESSAGE_COUNT
|
||||||
|
room.synced = true
|
||||||
|
|
||||||
|
for message in historyResponse.messages {
|
||||||
|
if let newMessage = MergedRoom.Message(from: message) {
|
||||||
|
let message = messageDatabase.upsert(newMessage)
|
||||||
|
|
||||||
|
message.room = room
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try context.save()
|
||||||
|
} catch {
|
||||||
|
print(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleRoomsResponse(_ subscriptionsResponse: SubscriptionsResponse, _ roomsResponse: RoomsResponse) {
|
||||||
|
let rooms = roomsResponse.update
|
||||||
|
let subscriptions = subscriptionsResponse.update
|
||||||
|
|
||||||
|
backgroundContext.performBackgroundTask { context in
|
||||||
|
let roomDatabase = RoomModel(context: context)
|
||||||
|
|
||||||
|
let roomIds = rooms.filter { room in !subscriptions.contains { room._id == $0.rid } }.map { $0._id }
|
||||||
|
|
||||||
|
let existingSubs = roomDatabase.fetch(ids: roomIds)
|
||||||
|
let mappedExistingSubs = subscriptions + existingSubs.compactMap { $0.response }
|
||||||
|
|
||||||
|
let mergedSubscriptions = mappedExistingSubs.compactMap { subscription in
|
||||||
|
let index = rooms.firstIndex { $0._id == subscription.rid }
|
||||||
|
|
||||||
|
guard let index else {
|
||||||
|
return MergedRoom(subscription, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
let room = rooms[index]
|
||||||
|
return MergedRoom(subscription, room)
|
||||||
|
}
|
||||||
|
|
||||||
|
let subsIds = mergedSubscriptions.compactMap { $0.id } + subscriptionsResponse.remove.compactMap { $0._id }
|
||||||
|
|
||||||
|
if subsIds.count > 0 {
|
||||||
|
let existingSubscriptions = roomDatabase.fetch(ids: subsIds)
|
||||||
|
let subsToUpdate = existingSubscriptions.filter { subscription in mergedSubscriptions.contains { subscription.id == $0.id } }
|
||||||
|
let subsToCreate = mergedSubscriptions.filter { subscription in !existingSubscriptions.contains { subscription.id == $0.id } }
|
||||||
|
let subsToDelete = existingSubscriptions.filter { subscription in !mergedSubscriptions.contains { subscription.id == $0.id } }
|
||||||
|
|
||||||
|
subsToCreate.forEach { subscription in
|
||||||
|
roomDatabase.upsert(subscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
subsToUpdate.forEach { subscription in
|
||||||
|
if let newRoom = mergedSubscriptions.first(where: { $0.id == subscription.id }) {
|
||||||
|
roomDatabase.upsert(newRoom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subsToDelete.forEach { subscription in
|
||||||
|
roomDatabase.delete(subscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try context.save()
|
||||||
|
} catch {
|
||||||
|
print(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Room {
|
||||||
|
var response: SubscriptionsResponse.Subscription? {
|
||||||
|
guard let id, let fname, let t, let rid else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return .init(
|
||||||
|
_id: id,
|
||||||
|
rid: rid,
|
||||||
|
name: name,
|
||||||
|
fname: fname,
|
||||||
|
t: t,
|
||||||
|
unread: Int(unread),
|
||||||
|
alert: alert,
|
||||||
|
lr: lr,
|
||||||
|
open: open,
|
||||||
|
_updatedAt: ts,
|
||||||
|
hideUnreadStatus: hideUnreadStatus
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NSManagedObjectContext {
|
||||||
|
func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void) {
|
||||||
|
perform {
|
||||||
|
block(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
@propertyWrapper
|
||||||
|
struct Dependency<T> {
|
||||||
|
private var dependency: T
|
||||||
|
|
||||||
|
init() {
|
||||||
|
guard let dependency = Store.resolve(T.self) else {
|
||||||
|
fatalError("No service of type \(T.self) registered!")
|
||||||
|
}
|
||||||
|
|
||||||
|
self.dependency = dependency
|
||||||
|
}
|
||||||
|
|
||||||
|
var wrappedValue: T {
|
||||||
|
get {
|
||||||
|
dependency
|
||||||
|
}
|
||||||
|
mutating set {
|
||||||
|
dependency = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,41 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol StoreInterface {
|
||||||
|
static func register<T>(_ type: T.Type, factory: @autoclosure @escaping () -> T)
|
||||||
|
static func resolve<T>(_ type: T.Type) -> T?
|
||||||
|
}
|
||||||
|
|
||||||
|
final class Store: StoreInterface {
|
||||||
|
private static var factories: [ObjectIdentifier: () -> Any] = [:]
|
||||||
|
private static var cache: [ObjectIdentifier: WeakRef<AnyObject>] = [:]
|
||||||
|
|
||||||
|
static func register<T>(_ type: T.Type, factory: @autoclosure @escaping () -> T) {
|
||||||
|
let identifier = ObjectIdentifier(type)
|
||||||
|
factories[identifier] = factory
|
||||||
|
cache[identifier] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resolve<T>(_ type: T.Type) -> T? {
|
||||||
|
let identifier = ObjectIdentifier(type)
|
||||||
|
|
||||||
|
if let dependency = cache[identifier]?.value {
|
||||||
|
return dependency as? T
|
||||||
|
} else {
|
||||||
|
let dependency = factories[identifier]?() as? T
|
||||||
|
|
||||||
|
if let dependency {
|
||||||
|
cache[identifier] = WeakRef(value: dependency as AnyObject)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dependency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class WeakRef<T: AnyObject> {
|
||||||
|
private(set) weak var value: T?
|
||||||
|
|
||||||
|
init(value: T) {
|
||||||
|
self.value = value
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
import WatchKit
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
final class ExtensionDelegate: NSObject, WKApplicationDelegate {
|
||||||
|
let router = AppRouter()
|
||||||
|
let database = DefaultDatabase()
|
||||||
|
|
||||||
|
func applicationDidFinishLaunching() {
|
||||||
|
UNUserNotificationCenter.current().delegate = self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ExtensionDelegate: UNUserNotificationCenterDelegate {
|
||||||
|
func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
didReceive response: UNNotificationResponse,
|
||||||
|
withCompletionHandler completionHandler: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
let userInfo = response.notification.request.content.userInfo
|
||||||
|
let ejson = userInfo["ejson"] as? String
|
||||||
|
let data = ejson?.data(using: .utf8)
|
||||||
|
|
||||||
|
guard let response = try? data?.decode(NotificationResponse.self) else { return }
|
||||||
|
|
||||||
|
deeplink(from: response)
|
||||||
|
|
||||||
|
completionHandler()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ExtensionDelegate {
|
||||||
|
private func deeplink(from response: NotificationResponse) {
|
||||||
|
guard let server = database.server(url: response.host) else { return }
|
||||||
|
guard let room = server.database.room(rid: response.rid) else { return }
|
||||||
|
|
||||||
|
router.route(to: [.loading, .roomList(server), .room(server, room)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NotificationResponse: Codable, Hashable {
|
||||||
|
let host: URL
|
||||||
|
let rid: String
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension Binding where Value == Bool {
|
||||||
|
init<Wrapped>(bindingOptional: Binding<Wrapped?>) {
|
||||||
|
self.init(
|
||||||
|
get: {
|
||||||
|
bindingOptional.wrappedValue != nil
|
||||||
|
},
|
||||||
|
set: { newValue in
|
||||||
|
guard newValue == false else { return }
|
||||||
|
|
||||||
|
/// We only handle `false` booleans to set our optional to `nil`
|
||||||
|
/// as we can't handle `true` for restoring the previous value.
|
||||||
|
bindingOptional.wrappedValue = nil
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Binding {
|
||||||
|
func mappedToBool<Wrapped>() -> Binding<Bool> where Value == Wrapped? {
|
||||||
|
Binding<Bool>(bindingOptional: self)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
static var titleLabels: Color {
|
||||||
|
Color(hex: 0xF2F3F5)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var `default`: Color {
|
||||||
|
Color(hex: 0xE4E7EA)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var secondaryInfo: Color {
|
||||||
|
Color(hex: 0x9EA2A8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Color {
|
||||||
|
init(hex: UInt, alpha: Double = 1) {
|
||||||
|
self.init(
|
||||||
|
.sRGB,
|
||||||
|
red: Double((hex >> 16) & 0xff) / 255,
|
||||||
|
green: Double((hex >> 08) & 0xff) / 255,
|
||||||
|
blue: Double((hex >> 00) & 0xff) / 255,
|
||||||
|
opacity: alpha
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Date {
|
||||||
|
static func - (lhs: Date, rhs: Date) -> TimeInterval {
|
||||||
|
return lhs.timeIntervalSinceReferenceDate - rhs.timeIntervalSinceReferenceDate
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension ToolbarItemPlacement {
|
||||||
|
static var `default`: Self {
|
||||||
|
if #available(watchOS 10.0, *) {
|
||||||
|
return .topBarLeading
|
||||||
|
} else {
|
||||||
|
return .automatic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,165 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct InfoMessage {
|
||||||
|
let msg: String
|
||||||
|
let username: String
|
||||||
|
let type: String
|
||||||
|
let role: String
|
||||||
|
let comment: String
|
||||||
|
}
|
||||||
|
|
||||||
|
func getInfoMessage(_ infoMessage: InfoMessage) -> LocalizedStringKey {
|
||||||
|
switch infoMessage.type {
|
||||||
|
case "rm":
|
||||||
|
return "message removed"
|
||||||
|
case "uj":
|
||||||
|
return "joined the channel"
|
||||||
|
case "ujt":
|
||||||
|
return "joined this team"
|
||||||
|
case "ut":
|
||||||
|
return "joined the conversation"
|
||||||
|
case "r":
|
||||||
|
return "changed room name to: \(infoMessage.msg)"
|
||||||
|
case "ru":
|
||||||
|
return "removed \(infoMessage.msg)"
|
||||||
|
case "au":
|
||||||
|
return "added \(infoMessage.msg)"
|
||||||
|
case "user-muted":
|
||||||
|
return "muted \(infoMessage.msg)"
|
||||||
|
case "room_changed_description":
|
||||||
|
return "changed room description to: \(infoMessage.msg)"
|
||||||
|
case "room_changed_announcement":
|
||||||
|
return "changed room announcement to: \(infoMessage.msg)"
|
||||||
|
case "room_changed_topic":
|
||||||
|
return "changed room topic to: \(infoMessage.msg)"
|
||||||
|
case "room_changed_privacy":
|
||||||
|
return "changed room to \(infoMessage.msg)"
|
||||||
|
case "room_changed_avatar":
|
||||||
|
return "changed room avatar"
|
||||||
|
case "message_snippeted":
|
||||||
|
return "created a snippet"
|
||||||
|
case "room_e2e_disabled":
|
||||||
|
return "disabled E2E encryption for this room"
|
||||||
|
case "room_e2e_enabled":
|
||||||
|
return "enabled E2E encryption for this room"
|
||||||
|
case "removed-user-from-team":
|
||||||
|
return "removed @\(infoMessage.msg) from this team"
|
||||||
|
case "added-user-to-team":
|
||||||
|
return "added @\(infoMessage.msg) to this team"
|
||||||
|
case "user-added-room-to-team":
|
||||||
|
return "added #\(infoMessage.msg) to this team"
|
||||||
|
case "user-converted-to-team":
|
||||||
|
return "converted #\(infoMessage.msg) to a team"
|
||||||
|
case "user-converted-to-channel":
|
||||||
|
return "converted #\(infoMessage.msg) to channel"
|
||||||
|
case "user-deleted-room-from-team":
|
||||||
|
return "deleted #\(infoMessage.msg)"
|
||||||
|
case "user-removed-room-from-team":
|
||||||
|
return "removed #\(infoMessage.msg) from this team"
|
||||||
|
case "room-disallowed-reacting":
|
||||||
|
return "disallowed reactions"
|
||||||
|
case "room-allowed-reacting":
|
||||||
|
return "allowed reactions"
|
||||||
|
case "room-set-read-only":
|
||||||
|
return "set room to read only"
|
||||||
|
case "room-removed-read-only":
|
||||||
|
return "removed read only permission"
|
||||||
|
case "user-unmuted":
|
||||||
|
return "unmuted \(infoMessage.msg)"
|
||||||
|
case "room-archived":
|
||||||
|
return "archived room"
|
||||||
|
case "room-unarchived":
|
||||||
|
return "unarchived room"
|
||||||
|
case "subscription-role-added":
|
||||||
|
return "defined \(infoMessage.msg) as \(infoMessage.role)"
|
||||||
|
case "subscription-role-removed":
|
||||||
|
return "removed \(infoMessage.msg) as \(infoMessage.role)"
|
||||||
|
case "message_pinned":
|
||||||
|
return "Pinned a message:"
|
||||||
|
case "ul":
|
||||||
|
return "left the channel"
|
||||||
|
case "ult":
|
||||||
|
return "has left the team"
|
||||||
|
case "jitsi_call_started":
|
||||||
|
return "Call started by \(infoMessage.username)"
|
||||||
|
case "omnichannel_placed_chat_on_hold":
|
||||||
|
return "Chat on hold: \(infoMessage.comment)"
|
||||||
|
case "omnichannel_on_hold_chat_resumed":
|
||||||
|
return "On hold chat resumed: \(infoMessage.comment)"
|
||||||
|
case "command":
|
||||||
|
return "returned the chat to the queue"
|
||||||
|
case "livechat-started":
|
||||||
|
return "Chat started"
|
||||||
|
case "livechat-close":
|
||||||
|
return "Conversation closed"
|
||||||
|
case "livechat_transfer_history":
|
||||||
|
return "New chat transfer: \(infoMessage.username) returned the chat to the queue"
|
||||||
|
default:
|
||||||
|
return "Unsupported system message"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageHaveAuthorName(_ messageType: String) -> Bool {
|
||||||
|
messagesWithAuthorName.contains(messageType)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension InfoMessage {
|
||||||
|
init(from message: Message) {
|
||||||
|
self.init(
|
||||||
|
msg: message.msg ?? "",
|
||||||
|
username: message.user?.username ?? "",
|
||||||
|
type: message.t ?? "",
|
||||||
|
role: message.role ?? "",
|
||||||
|
comment: message.comment ?? ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let messagesWithAuthorName: Set<String> = [
|
||||||
|
"r",
|
||||||
|
"ru",
|
||||||
|
"au",
|
||||||
|
"rm",
|
||||||
|
"uj",
|
||||||
|
"ujt",
|
||||||
|
"ut",
|
||||||
|
"ul",
|
||||||
|
"ult",
|
||||||
|
"message_pinned",
|
||||||
|
"message_snippeted",
|
||||||
|
"removed-user-from-team",
|
||||||
|
"added-user-to-team",
|
||||||
|
"user-added-room-to-team",
|
||||||
|
"user-converted-to-team",
|
||||||
|
"user-converted-to-channel",
|
||||||
|
"user-deleted-room-from-team",
|
||||||
|
"user-removed-room-from-team",
|
||||||
|
"omnichannel_placed_chat_on_hold",
|
||||||
|
"omnichannel_on_hold_chat_resumed",
|
||||||
|
"livechat_navigation_history",
|
||||||
|
"livechat_transcript_history",
|
||||||
|
"command",
|
||||||
|
"livechat-started",
|
||||||
|
"livechat-close",
|
||||||
|
"livechat_video_call",
|
||||||
|
"livechat_webrtc_video_call",
|
||||||
|
"livechat_transfer_history",
|
||||||
|
"room-archived",
|
||||||
|
"room-unarchived",
|
||||||
|
"user-muted",
|
||||||
|
"room_changed_description",
|
||||||
|
"room_changed_announcement",
|
||||||
|
"room_changed_topic",
|
||||||
|
"room_changed_privacy",
|
||||||
|
"room_changed_avatar",
|
||||||
|
"room_e2e_disabled",
|
||||||
|
"room_e2e_enabled",
|
||||||
|
"room-allowed-reacting",
|
||||||
|
"room-disallowed-reacting",
|
||||||
|
"room-set-read-only",
|
||||||
|
"room-removed-read-only",
|
||||||
|
"user-unmuted",
|
||||||
|
"room-unarchived",
|
||||||
|
"subscription-role-added",
|
||||||
|
"subscription-role-removed"
|
||||||
|
]
|
|
@ -0,0 +1,88 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
final class MessageFormatter {
|
||||||
|
private let message: Message
|
||||||
|
private let previousMessage: Message?
|
||||||
|
private let lastOpen: Date?
|
||||||
|
|
||||||
|
init(message: Message, previousMessage: Message?, lastOpen: Date?) {
|
||||||
|
self.message = message
|
||||||
|
self.previousMessage = previousMessage
|
||||||
|
self.lastOpen = lastOpen
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasDateSeparator() -> Bool {
|
||||||
|
if let previousMessage,
|
||||||
|
let previousMessageTS = previousMessage.ts,
|
||||||
|
let messageTS = message.ts,
|
||||||
|
Calendar.current.isDate(previousMessageTS, inSameDayAs: messageTS) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasUnreadSeparator() -> Bool {
|
||||||
|
guard let messageTS = message.ts, let lastOpen else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if previousMessage == nil {
|
||||||
|
return messageTS > lastOpen
|
||||||
|
} else if let previousMessage, let previousMessageTS = previousMessage.ts {
|
||||||
|
return messageTS >= lastOpen && previousMessageTS < lastOpen
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHeader() -> Bool {
|
||||||
|
if let previousMessage,
|
||||||
|
let previousMessageTS = previousMessage.ts,
|
||||||
|
let messageTS = message.ts,
|
||||||
|
Calendar.current.isDate(previousMessageTS, inSameDayAs: messageTS),
|
||||||
|
previousMessage.user?.username == message.user?.username,
|
||||||
|
!(previousMessage.groupable == false || message.groupable == false || message.room?.broadcast == true),
|
||||||
|
messageTS - previousMessageTS < 300,
|
||||||
|
message.t != "rm",
|
||||||
|
previousMessage.t != "rm" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func info() -> LocalizedStringKey? {
|
||||||
|
switch message.t {
|
||||||
|
case .some:
|
||||||
|
return getInfoMessage(.init(from: message))
|
||||||
|
case .none:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func date() -> String? {
|
||||||
|
guard let ts = message.ts else { return nil }
|
||||||
|
|
||||||
|
let dateFormatter = DateFormatter()
|
||||||
|
|
||||||
|
dateFormatter.locale = Locale.current
|
||||||
|
dateFormatter.timeZone = TimeZone.current
|
||||||
|
dateFormatter.timeStyle = .none
|
||||||
|
dateFormatter.dateStyle = .long
|
||||||
|
|
||||||
|
return dateFormatter.string(from: ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func time() -> String? {
|
||||||
|
guard let ts = message.ts else { return nil }
|
||||||
|
|
||||||
|
let dateFormatter = DateFormatter()
|
||||||
|
|
||||||
|
dateFormatter.locale = Locale.current
|
||||||
|
dateFormatter.timeZone = TimeZone.current
|
||||||
|
dateFormatter.timeStyle = .short
|
||||||
|
dateFormatter.dateStyle = .none
|
||||||
|
|
||||||
|
return dateFormatter.string(from: ts)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class RoomFormatter {
|
||||||
|
private let room: Room
|
||||||
|
private let server: Server
|
||||||
|
|
||||||
|
init(room: Room, server: Server) {
|
||||||
|
self.room = room
|
||||||
|
self.server = server
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String? {
|
||||||
|
if isGroupChat, !(room.name != nil && room.name?.isEmpty == false), let usernames = room.usernames {
|
||||||
|
return usernames
|
||||||
|
.filter { $0 == server.loggedUser.username }
|
||||||
|
.sorted()
|
||||||
|
.joined(separator: ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
if room.t != "d" {
|
||||||
|
return room.fname ?? room.name
|
||||||
|
}
|
||||||
|
|
||||||
|
if room.prid != nil || server.useRealName {
|
||||||
|
return room.fname ?? room.name
|
||||||
|
}
|
||||||
|
|
||||||
|
return room.name
|
||||||
|
}
|
||||||
|
|
||||||
|
var isGroupChat: Bool {
|
||||||
|
if let uids = room.uids, uids.count > 2 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if let usernames = room.usernames, usernames.count > 2 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
typealias CancelBag = Set<AnyCancellable>
|
||||||
|
|
||||||
|
extension CancelBag {
|
||||||
|
mutating func cancelAll() {
|
||||||
|
forEach { $0.cancel() }
|
||||||
|
removeAll()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class ImageLoader: ObservableObject {
|
||||||
|
@Dependency private var client: RocketChatClientProtocol
|
||||||
|
|
||||||
|
@Published private(set) var image: UIImage?
|
||||||
|
|
||||||
|
private var cancellable: AnyCancellable?
|
||||||
|
|
||||||
|
private let url: URL
|
||||||
|
|
||||||
|
init(url: URL) {
|
||||||
|
self.url = url
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func load() {
|
||||||
|
cancellable = client.session.dataTaskPublisher(for: url)
|
||||||
|
.map { UIImage(data: $0.data) }
|
||||||
|
.replaceError(with: nil)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] in self?.image = $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
cancellable?.cancel()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct MergedRoom {
|
||||||
|
let id: String
|
||||||
|
let name: String?
|
||||||
|
let fname: String?
|
||||||
|
let t: String
|
||||||
|
let unread: Int
|
||||||
|
let alert: Bool
|
||||||
|
let lr: Date?
|
||||||
|
let open: Bool?
|
||||||
|
let rid: String
|
||||||
|
let hideUnreadStatus: Bool?
|
||||||
|
|
||||||
|
let archived: Bool?
|
||||||
|
let broadcast: Bool?
|
||||||
|
let encrypted: Bool?
|
||||||
|
let isReadOnly: Bool?
|
||||||
|
let prid: String?
|
||||||
|
let teamMain: Bool?
|
||||||
|
let ts: Date?
|
||||||
|
let uids: [String]?
|
||||||
|
let updatedAt: Date?
|
||||||
|
let usernames: [String]?
|
||||||
|
let lastMessage: Message?
|
||||||
|
let lm: Date?
|
||||||
|
|
||||||
|
struct Message {
|
||||||
|
let _id: String
|
||||||
|
let rid: String
|
||||||
|
let msg: String
|
||||||
|
let u: User
|
||||||
|
let ts: Date
|
||||||
|
let attachments: [Attachment]?
|
||||||
|
let t: String?
|
||||||
|
let groupable: Bool?
|
||||||
|
let editedAt: Date?
|
||||||
|
let role: String?
|
||||||
|
let comment: String?
|
||||||
|
|
||||||
|
struct User {
|
||||||
|
let _id: String
|
||||||
|
let username: String?
|
||||||
|
let name: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Attachment {
|
||||||
|
let title: String?
|
||||||
|
let imageURL: URL?
|
||||||
|
let audioURL: URL?
|
||||||
|
let description: String?
|
||||||
|
let dimensions: Dimensions?
|
||||||
|
|
||||||
|
struct Dimensions {
|
||||||
|
let width: Double
|
||||||
|
let height: Double
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,58 @@
|
||||||
|
extension MergedRoom {
|
||||||
|
init(_ subscription: SubscriptionsResponse.Subscription, _ room: RoomsResponse.Room?) {
|
||||||
|
id = subscription._id
|
||||||
|
name = subscription.name ?? room?.fname
|
||||||
|
fname = subscription.fname
|
||||||
|
t = subscription.t
|
||||||
|
unread = subscription.unread
|
||||||
|
alert = subscription.alert
|
||||||
|
lr = subscription.lr
|
||||||
|
open = subscription.open
|
||||||
|
rid = subscription.rid
|
||||||
|
hideUnreadStatus = subscription.hideUnreadStatus
|
||||||
|
|
||||||
|
if let room {
|
||||||
|
if room._updatedAt != nil {
|
||||||
|
updatedAt = room._updatedAt
|
||||||
|
lastMessage = .init(from: room.lastMessage?.value)
|
||||||
|
archived = room.archived ?? false
|
||||||
|
usernames = room.usernames
|
||||||
|
uids = room.uids
|
||||||
|
} else {
|
||||||
|
updatedAt = nil
|
||||||
|
lastMessage = nil
|
||||||
|
archived = nil
|
||||||
|
usernames = nil
|
||||||
|
uids = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastRoomUpdate = room.lm ?? room.ts ?? subscription._updatedAt
|
||||||
|
|
||||||
|
if let lr = subscription.lr, let lastRoomUpdate {
|
||||||
|
ts = max(lr, lastRoomUpdate)
|
||||||
|
} else {
|
||||||
|
ts = lastRoomUpdate
|
||||||
|
}
|
||||||
|
|
||||||
|
isReadOnly = room.ro ?? false
|
||||||
|
broadcast = room.broadcast
|
||||||
|
encrypted = room.encrypted
|
||||||
|
teamMain = room.teamMain
|
||||||
|
prid = room.prid
|
||||||
|
lm = room.lm
|
||||||
|
} else {
|
||||||
|
updatedAt = nil
|
||||||
|
lastMessage = nil
|
||||||
|
archived = nil
|
||||||
|
usernames = nil
|
||||||
|
uids = nil
|
||||||
|
ts = nil
|
||||||
|
isReadOnly = nil
|
||||||
|
broadcast = nil
|
||||||
|
encrypted = nil
|
||||||
|
teamMain = nil
|
||||||
|
prid = nil
|
||||||
|
lm = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|