diff --git a/app/mobile/ios/Databag.xcodeproj/project.pbxproj b/app/mobile/ios/Databag.xcodeproj/project.pbxproj index 84b94ec7..9b280ea9 100644 --- a/app/mobile/ios/Databag.xcodeproj/project.pbxproj +++ b/app/mobile/ios/Databag.xcodeproj/project.pbxproj @@ -622,7 +622,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.12; + MARKETING_VERSION = 1.13; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -656,7 +656,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.12; + MARKETING_VERSION = 1.13; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/app/mobile/ios/Podfile.lock b/app/mobile/ios/Podfile.lock index c32f9a84..adf9b1c7 100644 --- a/app/mobile/ios/Podfile.lock +++ b/app/mobile/ios/Podfile.lock @@ -64,6 +64,15 @@ PODS: - hermes-engine/Pre-built (0.71.3) - JitsiWebRTC (106.0.0) - libevent (2.1.12) + - libwebp (1.2.4): + - libwebp/demux (= 1.2.4) + - libwebp/mux (= 1.2.4) + - libwebp/webp (= 1.2.4) + - libwebp/demux (1.2.4): + - libwebp/webp + - libwebp/mux (1.2.4): + - libwebp/demux + - libwebp/webp (1.2.4) - nanopb (2.30909.0): - nanopb/decode (= 2.30909.0) - nanopb/encode (= 2.30909.0) @@ -321,8 +330,12 @@ PODS: - React-jsinspector (0.71.3) - React-logger (0.71.3): - glog + - react-native-create-thumbnail (1.6.4): + - React-Core - react-native-document-picker (8.1.3): - React-Core + - react-native-image-resizer (3.0.5): + - React-Core - react-native-keep-awake (1.1.0): - React-Core - react-native-receive-sharing-intent (2.0.0): @@ -437,6 +450,10 @@ PODS: - React-Core - RNDeviceInfo (10.4.0): - React-Core + - RNFastImage (8.6.3): + - React-Core + - SDWebImage (~> 5.11.1) + - SDWebImageWebPCoder (~> 0.8.4) - RNFBApp (17.2.0): - Firebase/CoreOnly (= 10.5.0) - React-Core @@ -445,6 +462,8 @@ PODS: - FirebaseCoreExtension (= 10.5.0) - React-Core - RNFBApp + - RNFS (2.20.0): + - React-Core - RNGestureHandler (2.9.0): - React-Core - RNImageCropPicker (0.39.0): @@ -490,6 +509,12 @@ PODS: - React-Core - RNVectorIcons (9.2.0): - React-Core + - SDWebImage (5.11.1): + - SDWebImage/Core (= 5.11.1) + - SDWebImage/Core (5.11.1) + - SDWebImageWebPCoder (0.8.5): + - libwebp (~> 1.0) + - SDWebImage/Core (~> 5.10) - TOCropViewController (2.6.1) - Yoga (1.14.0) @@ -516,7 +541,9 @@ DEPENDENCIES: - React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`) - React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector`) - React-logger (from `../node_modules/react-native/ReactCommon/logger`) + - react-native-create-thumbnail (from `../node_modules/react-native-create-thumbnail`) - react-native-document-picker (from `../node_modules/react-native-document-picker`) + - "react-native-image-resizer (from `../node_modules/@bam.tech/react-native-image-resizer`)" - "react-native-keep-awake (from `../node_modules/@sayem314/react-native-keep-awake`)" - react-native-receive-sharing-intent (from `../node_modules/react-native-receive-sharing-intent`) - react-native-rsa-native (from `../node_modules/react-native-rsa-native`) @@ -541,8 +568,10 @@ DEPENDENCIES: - rn-fetch-blob (from `../node_modules/rn-fetch-blob`) - "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)" - RNDeviceInfo (from `../node_modules/react-native-device-info`) + - RNFastImage (from `../node_modules/react-native-fast-image`) - "RNFBApp (from `../node_modules/@react-native-firebase/app`)" - "RNFBMessaging (from `../node_modules/@react-native-firebase/messaging`)" + - RNFS (from `../node_modules/react-native-fs`) - RNGestureHandler (from `../node_modules/react-native-gesture-handler`) - RNImageCropPicker (from `../node_modules/react-native-image-crop-picker`) - RNReanimated (from `../node_modules/react-native-reanimated`) @@ -564,8 +593,11 @@ SPEC REPOS: - GoogleUtilities - JitsiWebRTC - libevent + - libwebp - nanopb - PromisesObjC + - SDWebImage + - SDWebImageWebPCoder - TOCropViewController EXTERNAL SOURCES: @@ -609,8 +641,12 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/jsinspector" React-logger: :path: "../node_modules/react-native/ReactCommon/logger" + react-native-create-thumbnail: + :path: "../node_modules/react-native-create-thumbnail" react-native-document-picker: :path: "../node_modules/react-native-document-picker" + react-native-image-resizer: + :path: "../node_modules/@bam.tech/react-native-image-resizer" react-native-keep-awake: :path: "../node_modules/@sayem314/react-native-keep-awake" react-native-receive-sharing-intent: @@ -659,10 +695,14 @@ EXTERNAL SOURCES: :path: "../node_modules/@react-native-clipboard/clipboard" RNDeviceInfo: :path: "../node_modules/react-native-device-info" + RNFastImage: + :path: "../node_modules/react-native-fast-image" RNFBApp: :path: "../node_modules/@react-native-firebase/app" RNFBMessaging: :path: "../node_modules/@react-native-firebase/messaging" + RNFS: + :path: "../node_modules/react-native-fs" RNGestureHandler: :path: "../node_modules/react-native-gesture-handler" RNImageCropPicker: @@ -696,6 +736,7 @@ SPEC CHECKSUMS: hermes-engine: 38bfe887e456b33b697187570a08de33969f5db7 JitsiWebRTC: f441eb0e2d67f0588bf24e21c5162e97342714fb libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 + libwebp: f62cb61d0a484ba548448a4bd52aabf150ff6eef nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431 PromisesObjC: ab77feca74fa2823e7af4249b8326368e61014cb RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1 @@ -712,7 +753,9 @@ SPEC CHECKSUMS: React-jsiexecutor: 515b703d23ffadeac7687bc2d12fb08b90f0aaa1 React-jsinspector: 9f7c9137605e72ca0343db4cea88006cb94856dd React-logger: 957e5dc96d9dbffc6e0f15e0ee4d2b42829ff207 + react-native-create-thumbnail: e022bcdcba8a0b4529a50d3fa1a832ec921be39d react-native-document-picker: 958e2bc82e128be69055be261aeac8d872c8d34c + react-native-image-resizer: 00ceb0e05586c7aadf061eea676957a6c2ec60fa react-native-keep-awake: acbee258db16483744910f0da3ace39eb9ab47fd react-native-receive-sharing-intent: 62ab28c50e6ae56d32b9e841d7452091312a0bc7 react-native-rsa-native: 12132eb627797529fdb1f0d22fd0f8f9678df64a @@ -737,14 +780,18 @@ SPEC CHECKSUMS: rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba RNCClipboard: 2834e1c4af68697089cdd455ee4a4cdd198fa7dd RNDeviceInfo: 749f2e049dcd79e2e44f134f66b73a06951b5066 + RNFastImage: 5c9c9fed9c076e521b3f509fe79e790418a544e8 RNFBApp: 4f8ea53443d52c7db793234d2398a357fc6cfbf1 RNFBMessaging: c686471358d20d54f716a8b7b7f10f8944c966ec + RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 RNGestureHandler: 071d7a9ad81e8b83fe7663b303d132406a7d8f39 RNImageCropPicker: 14fe1c29298fb4018f3186f455c475ab107da332 RNReanimated: cc5e3aa479cb9170bcccf8204291a6950a3be128 RNScreens: 218801c16a2782546d30bd2026bb625c0302d70f RNShare: d82e10f6b7677f4b0048c23709bd04098d5aee6c RNVectorIcons: fcc2f6cb32f5735b586e66d14103a74ce6ad61f8 + SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d + SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d TOCropViewController: edfd4f25713d56905ad1e0b9f5be3fbe0f59c863 Yoga: 5ed1699acbba8863755998a4245daa200ff3817b diff --git a/app/mobile/package.json b/app/mobile/package.json index 8107ce7d..2ace88f9 100644 --- a/app/mobile/package.json +++ b/app/mobile/package.json @@ -10,6 +10,7 @@ "test": "jest" }, "dependencies": { + "@bam.tech/react-native-image-resizer": "^3.0.5", "@braintree/sanitize-url": "^6.0.2", "@react-native-clipboard/clipboard": "^1.11.1", "@react-native-firebase/app": "^17.2.0", @@ -27,8 +28,11 @@ "react": "18.2.0", "react-native": "0.71.3", "react-native-base64": "^0.2.1", + "react-native-create-thumbnail": "^1.6.4", "react-native-device-info": "^10.4.0", "react-native-document-picker": "^8.1.3", + "react-native-fast-image": "^8.6.3", + "react-native-fs": "^2.20.0", "react-native-gesture-handler": "^2.9.0", "react-native-image-crop-picker": "^0.39.0", "react-native-incall-manager": "^4.0.1", diff --git a/app/mobile/src/context/sealUtil.js b/app/mobile/src/context/sealUtil.js index 47027d8b..8ab8658d 100644 --- a/app/mobile/src/context/sealUtil.js +++ b/app/mobile/src/context/sealUtil.js @@ -57,6 +57,27 @@ export function updateChannelSubject(subject, contentKey) { return { subjectEncrypted, subjectIv }; } +export function encryptBlock(block, contentKey) { + const key = CryptoJS.enc.Hex.parse(contentKey); + const iv = CryptoJS.lib.WordArray.random(128 / 8); + const encrypted = CryptoJS.AES.encrypt(block, key, { iv: iv }); + const blockEncrypted = encrypted.ciphertext.toString(CryptoJS.enc.Base64) + const blockIv = iv.toString(); + + return { blockEncrypted, blockIv }; +} + +export function decryptBlock(blockEncrypted, blockIv, contentKey) { + const iv = CryptoJS.enc.Hex.parse(blockIv); + const key = CryptoJS.enc.Hex.parse(contentKey); + const enc = CryptoJS.enc.Base64.parse(blockEncrypted); + const cipher = CryptoJS.lib.CipherParams.create({ ciphertext: enc, iv: iv }); + const dec = CryptoJS.AES.decrypt(cipher, key, { iv: iv }); + const block = dec.toString(CryptoJS.enc.Utf8); + + return block; +} + export function decryptChannelSubject(subject, contentKey) { const { subjectEncrypted, subjectIv } = JSON.parse(subject); const iv = CryptoJS.enc.Hex.parse(subjectIv); diff --git a/app/mobile/src/context/useAppContext.hook.js b/app/mobile/src/context/useAppContext.hook.js index d0ad2a60..2abddb38 100644 --- a/app/mobile/src/context/useAppContext.hook.js +++ b/app/mobile/src/context/useAppContext.hook.js @@ -67,8 +67,9 @@ export function useAppContext() { }, []); const setSession = async () => { - const { loginTimestamp } = access.current; + const { loginTimestamp, guid } = access.current; updateState({ session: true, loginTimestamp, status: 'connecting' }); + await store.actions.updateDb(guid); await account.actions.setSession(access.current); await profile.actions.setSession(access.current); await card.actions.setSession(access.current); diff --git a/app/mobile/src/context/useCardContext.hook.js b/app/mobile/src/context/useCardContext.hook.js index 10c53111..ecf3190e 100644 --- a/app/mobile/src/context/useCardContext.hook.js +++ b/app/mobile/src/context/useCardContext.hook.js @@ -493,6 +493,14 @@ export function useCardContext() { const { guid } = access.current || {}; return await store.actions.getCardChannelTopicItems(guid, cardId, channelId); }, + getTopicItemsId: async (cardId, channelId) => { + const { guid } = access.current || {}; + return await store.actions.getCardChannelTopicItemsId(guid, cardId, channelId); + }, + getTopicItemsById: async (cardId, channelId, topics) => { + const { guid } = access.current || {}; + return await store.actions.getCardChannelTopicItemsById(guid, cardId, channelId, topics); + }, setTopicItem: async (cardId, channelId, topicId, topic) => { const { guid } = access.current || {}; return await store.actions.setCardChannelTopicItem(guid, cardId, channelId, topicId, topic); diff --git a/app/mobile/src/context/useChannelContext.hook.js b/app/mobile/src/context/useChannelContext.hook.js index be785ba4..0df0e830 100644 --- a/app/mobile/src/context/useChannelContext.hook.js +++ b/app/mobile/src/context/useChannelContext.hook.js @@ -263,6 +263,14 @@ export function useChannelContext() { const { guid } = access.current || {}; return await store.actions.getChannelTopicItems(guid, channelId); }, + getTopicItemsId: async (channelId) => { + const { guid } = access.current || {}; + return await store.actions.getChannelTopicItemsId(guid, channelId); + }, + getTopicItemsById: async (channelId, topics) => { + const { guid } = access.current || {}; + return await store.actions.getChannelTopicItemsById(guid, channelId, topics); + }, setTopicItem: async (channelId, topic) => { const { guid } = access.current || {}; return await store.actions.setChannelTopicItem(guid, channelId, topic); diff --git a/app/mobile/src/context/useConversationContext.hook.js b/app/mobile/src/context/useConversationContext.hook.js index ce77b23a..a623e392 100644 --- a/app/mobile/src/context/useConversationContext.hook.js +++ b/app/mobile/src/context/useConversationContext.hook.js @@ -7,7 +7,7 @@ import { ProfileContext } from 'context/ProfileContext'; import CryptoJS from 'crypto-js'; export function useConversationContext() { - const COUNT = 48; + const COUNT = 32; const [state, setState] = useState({ loaded: false, @@ -25,6 +25,7 @@ export function useConversationContext() { const syncing = useRef(false); const update = useRef(false); const loaded = useRef(false); + const stored = useRef([]); const conversationId = useRef(null); const topics = useRef(new Map()); @@ -63,7 +64,26 @@ export function useConversationContext() { if (channelValue) { if (!loaded.current) { - const topicItems = await getTopicItems(cardId, channelId); + + stored.current = await getTopicItemsId(cardId, channelId); + stored.current.sort((a,b) => { + if (a.created > b.created) { + return -1; + } + if (a.created < b.created) { + return 1; + } + return 0; + }); + + const ids = []; + for (let i = 0; i < COUNT; i++) { + if (stored.current.length > 0) { + ids.push(stored.current.shift().topicId); + } + } + + const topicItems = await getTopicItemsById(cardId, channelId, ids); for (let topic of topicItems) { topics.current.set(topic.topicId, topic); } @@ -94,12 +114,27 @@ export function useConversationContext() { updateState({ loaded: true, offsync: false, topics: topics.current, card: cardValue, channel: channelValue }); } else if (loadMore) { - const delta = await getTopicDelta(cardId, channelId, null, COUNT, null, curTopicMarker.current); - const marker = delta.marker ? delta.marker : 1; - await setTopicDelta(cardId, channelId, delta.topics); - await setTopicMarker(cardId, channelId, marker); - curTopicMarker.current = marker; - updateState({ loaded: true, offsync: false, topics: topics.current, card: cardValue, channel: channelValue }); + if (stored.current.length > 0) { + const ids = []; + for (let i = 0; i < COUNT; i++) { + if (stored.current.length > 0) { + ids.push(stored.current.shift().topicId); + } + } + const topicItems = await getTopicItemsById(cardId, channelId, ids); + for (let topic of topicItems) { + topics.current.set(topic.topicId, topic); + } + updateState({ loaded: true, topics: topics.current, card: cardValue, channel: channelValue }); + } + else { + const delta = await getTopicDelta(cardId, channelId, null, COUNT, null, curTopicMarker.current); + const marker = delta.marker ? delta.marker : 1; + await setTopicDelta(cardId, channelId, delta.topics); + await setTopicMarker(cardId, channelId, marker); + curTopicMarker.current = marker; + updateState({ loaded: true, offsync: false, topics: topics.current, card: cardValue, channel: channelValue }); + } } else if (ignoreRevision || topicRevision > curSyncRevision.current) { const delta = await getTopicDelta(cardId, channelId, curSyncRevision.current, null, curTopicMarker.current, null); @@ -134,19 +169,19 @@ export function useConversationContext() { if (entry.data) { if (entry.data.topicDetail) { const item = mapTopicEntry(entry); - setTopicItem(cardId, channelId, item); + await setTopicItem(cardId, channelId, item); topics.current.set(item.topicId, item); } else { const topic = await getTopic(cardId, channelId, entry.id); const item = mapTopicEntry(topic); - setTopicItem(cardId, channelId, item); + await setTopicItem(cardId, channelId, item); topics.current.set(item.topicId, item); } } else { topics.current.delete(entry.id); - clearTopicItem(entry.id); + clearTopicItem(cardId, channelId, entry.id); } } } @@ -347,6 +382,20 @@ export function useConversationContext() { }, } + const getTopicItemsId = async (cardId, channelId) => { + if (cardId) { + return await card.actions.getTopicItemsId(cardId, channelId); + } + return await channel.actions.getTopicItemsId(channelId); + } + + const getTopicItemsById = async (cardId, channelId, topics) => { + if (cardId) { + return await card.actions.getTopicItemsById(cardId, channelId, topics); + } + return await channel.actions.getTopicItemsById(channelId, topics); + } + const getTopicItems = async (cardId, channelId) => { if (cardId) { return await card.actions.getTopicItems(cardId, channelId); diff --git a/app/mobile/src/context/useStoreContext.hook.js b/app/mobile/src/context/useStoreContext.hook.js index 737b0ad1..3778ef77 100644 --- a/app/mobile/src/context/useStoreContext.hook.js +++ b/app/mobile/src/context/useStoreContext.hook.js @@ -13,10 +13,23 @@ export function useStoreContext() { const initSession = async (guid) => { await db.current.executeSql(`CREATE TABLE IF NOT EXISTS channel_${guid} (channel_id text, revision integer, detail_revision integer, topic_revision integer, topic_marker integer, blocked integer, sync_revision integer, detail text, unsealed_detail text, summary text, unsealed_summary text, offsync integer, read_revision integer, unique(channel_id))`); - await db.current.executeSql(`CREATE TABLE IF NOT EXISTS channel_topic_${guid} (channel_id text, topic_id text, revision integer, detail_revision integer, blocked integer, detail text, unsealed_detail text, unique(channel_id, topic_id))`); + await db.current.executeSql(`CREATE TABLE IF NOT EXISTS channel_topic_${guid} (channel_id text, topic_id text, revision integer, created integer, detail_revision integer, blocked integer, detail text, unsealed_detail text, unique(channel_id, topic_id))`); await db.current.executeSql(`CREATE TABLE IF NOT EXISTS card_${guid} (card_id text, revision integer, detail_revision integer, profile_revision integer, detail text, profile text, notified_view integer, notified_article integer, notified_profile integer, notified_channel integer, offsync integer, blocked integer, unique(card_id))`); await db.current.executeSql(`CREATE TABLE IF NOT EXISTS card_channel_${guid} (card_id text, channel_id text, revision integer, detail_revision integer, topic_revision integer, topic_marker integer, sync_revision integer, detail text, unsealed_detail text, summary text, unsealed_summary text, offsync integer, blocked integer, read_revision integer, unique(card_id, channel_id))`); - await db.current.executeSql(`CREATE TABLE IF NOT EXISTS card_channel_topic_${guid} (card_id text, channel_id text, topic_id text, revision integer, detail_revision integer, blocked integer, detail text, unsealed_detail text, unique(card_id, channel_id, topic_id))`); + await db.current.executeSql(`CREATE TABLE IF NOT EXISTS card_channel_topic_${guid} (card_id text, channel_id text, topic_id text, revision integer, created integer, detail_revision integer, blocked integer, detail text, unsealed_detail text, unique(card_id, channel_id, topic_id))`); + } + + const hasColumn = async (table, column) => { + const pragma = await db.current.executeSql(`PRAGMA table_info(${table})`); + if (pragma?.length === 1) { + for (let i = 0; i < pragma[0].rows.length; i++) { + const col = pragma[0].rows.item(i); + if (col.name === column) { + return true; + } + } + } + return false } const actions = { @@ -28,6 +41,16 @@ export function useStoreContext() { await db.current.executeSql("INSERT OR IGNORE INTO app (key, value) values ('session', null);"); return await getAppValue(db.current, 'session'); }, + updateDb: async (guid) => { + const hasChannel = await hasColumn(`channel_topic_${guid}`, 'created'); + if (!hasChannel) { + await db.current.executeSql(`ALTER TABLE channel_topic_${guid} ADD COLUMN created integer default 0`); + } + const hasCardChannel = await hasColumn(`card_channel_topic_${guid}`, 'created'); + if (!hasCardChannel) { + await db.current.executeSql(`ALTER TABLE card_channel_topic_${guid} ADD COLUMN created integer default 0`); + } + }, setSession: async (access) => { await initSession(access.guid); await db.current.executeSql("UPDATE app SET value=? WHERE key='session';", [encodeObject(access)]); @@ -240,9 +263,28 @@ export function useStoreContext() { unsealedDetail: decodeObject(topic.unsealed_detail), })); }, + getChannelTopicItemsId: async (guid, channelId) => { + const values = await getAppValues(db.current, `SELECT topic_id, created FROM channel_topic_${guid} WHERE channel_id=?`, [channelId]); + return values.map(topic => ({ + topicId: topic.topic_id, + created: topic.created, + })); + }, + getChannelTopicItemsById: async (guid, channelId, topics) => { + const q = topics.map(() => '?'); + const values = await getAppValues(db.current, `SELECT topic_id, revision, blocked, detail_revision, detail, unsealed_detail FROM channel_topic_${guid} WHERE channel_id=? AND topic_id in (${q.join(',')})`, [channelId, ...topics]); + return values.map(topic => ({ + topicId: topic.topic_id, + revision: topic.revision, + blocked: topic.blocked, + detailRevision: topic.detail_revision, + detail: decodeObject(topic.detail), + unsealedDetail: decodeObject(topic.unsealed_detail), + })); + }, setChannelTopicItem: async (guid, channelId, topic) => { const { topicId, revision, detailRevision, detail } = topic; - await db.current.executeSql(`INSERT OR REPLACE INTO channel_topic_${guid} (channel_id, topic_id, revision, detail_revision, blocked, detail, unsealed_detail) values (?, ?, ?, ?, false, ?, null);`, [channelId, topicId, revision, detailRevision, encodeObject(detail)]); + await db.current.executeSql(`INSERT OR REPLACE INTO channel_topic_${guid} (channel_id, topic_id, revision, created, detail_revision, blocked, detail, unsealed_detail) values (?, ?, ?, ?, ?, false, ?, null);`, [channelId, topicId, revision, detail?.created, detailRevision, encodeObject(detail)]); }, setChannelTopicItemUnsealedDetail: async (guid, channelId, topicId, revision, unsealed) => { await db.current.executeSql(`UPDATE channel_topic_${guid} set unsealed_detail=? where detail_revision=? AND channel_id=? AND topic_id=?`, [encodeObject(unsealed), revision, channelId, topicId]); @@ -329,11 +371,30 @@ export function useStoreContext() { detailRevision: topic.detail_revision, detail: decodeObject(topic.detail), unsealedDetail: decodeObject(topic.unsealed_detail), - })); - }, + })); + }, + getCardChannelTopicItemsId: async (guid, cardId, channelId) => { + const values = await getAppValues(db.current, `SELECT topic_id, created FROM card_channel_topic_${guid} WHERE card_id=? AND channel_id=?`, [cardId, channelId]); + return values.map(topic => ({ + topicId: topic.topic_id, + created: topic.created, + })); + }, + getCardChannelTopicItemsById: async (guid, cardId, channelId, topics) => { + const q = topics.map(() => '?'); + const values = await getAppValues(db.current, `SELECT topic_id, revision, blocked, detail_revision, detail, unsealed_detail FROM card_channel_topic_${guid} WHERE card_id=? AND channel_id=? AND topic_id in (${q.join(',')})`, [cardId, channelId, ...topics]); + return values.map(topic => ({ + topicId: topic.topic_id, + revision: topic.revision, + blocked: topic.blocked, + detailRevision: topic.detail_revision, + detail: decodeObject(topic.detail), + unsealedDetail: decodeObject(topic.unsealed_detail), + })); + }, setCardChannelTopicItem: async (guid, cardId, channelId, topic) => { const { topicId, revision, detailRevision, detail } = topic; - await db.current.executeSql(`INSERT OR REPLACE INTO card_channel_topic_${guid} (card_id, channel_id, topic_id, revision, detail_revision, detail, unsealed_detail) values (?, ?, ?, ?, ?, ?, null);`, [cardId, channelId, topicId, revision, detailRevision, encodeObject(detail)]); + await db.current.executeSql(`INSERT OR REPLACE INTO card_channel_topic_${guid} (card_id, channel_id, topic_id, revision, created, detail_revision, detail, unsealed_detail) values (?, ?, ?, ?, ?, ?, ?, null);`, [cardId, channelId, topicId, revision, topic?.created, detailRevision, encodeObject(detail)]); }, setCardChannelTopicItemUnsealedDetail: async (guid, cardId, channelId, topicId, revision, unsealed) => { await db.current.executeSql(`UPDATE card_channel_topic_${guid} set unsealed_detail=? where detail_revision=? AND card_id=? AND channel_id=? AND topic_id=?`, [encodeObject(unsealed), revision, cardId, channelId, topicId]); diff --git a/app/mobile/src/context/useUploadContext.hook.js b/app/mobile/src/context/useUploadContext.hook.js index 20ddbd7e..efd0677b 100644 --- a/app/mobile/src/context/useUploadContext.hook.js +++ b/app/mobile/src/context/useUploadContext.hook.js @@ -1,5 +1,10 @@ import { useState, useRef } from 'react'; import axios from 'axios'; +import { createThumbnail } from "react-native-create-thumbnail"; +import ImageResizer from '@bam.tech/react-native-image-resizer'; +import RNFS from 'react-native-fs'; + +const ENCRYPTED_BLOCK_SIZE = (1024 * 1024); export function useUploadContext() { @@ -58,14 +63,12 @@ export function useUploadContext() { const actions = { addTopic: (node, token, channelId, topicId, files, success, failure, cardId) => { - const url = cardId ? - `https://${node}/content/channels/${channelId}/topics/${topicId}/assets?contact=${token}` : - `https://${node}/content/channels/${channelId}/topics/${topicId}/assets?agent=${token}`; const key = cardId ? `${cardId}:${channelId}` : `:${channelId}`; const controller = new AbortController(); const entry = { index: index.current, - url: url, + baseUrl: cardId ? `https://${node}/content/channels/${channelId}/topics/${topicId}/` : `https://${node}/content/channels/${channelId}/topics/${topicId}/`, + urlParams: cardId ? `?contact=${token}` : `?agent=${token}`, files, assets: [], current: null, @@ -116,6 +119,23 @@ export function useUploadContext() { return { state, actions } } +async function getThumb(file, type, position) { + if (type === 'image') { + const thumb = await ImageResizer.createResizedImage(file, 192, 192, "JPEG", 50, 0, null); + const base = await RNFS.readFile(thumb.path, 'base64') + return `data:image/jpeg;base64,${base}`; + } + else if (type === 'video') { + const shot = await createThumbnail({ url: url, timeStamp: position * 1000 }) + const thumb = await ImageResizer.createResizedImage('file://' + shot.path, 192, 192, "JPEG", 50, 0, null); + const base = await RNFS.readFile(thumb.path, 'base64') + return `data:image/jpeg;base64,${base}`; + } + else { + return null + } +} + async function upload(entry, update, complete) { if (!entry.files?.length) { try { @@ -133,7 +153,30 @@ async function upload(entry, update, complete) { const file = entry.files.shift(); entry.active = {}; try { - if (file.type === 'image') { + if (file.encrypted) { + const { data, type, size, getEncryptedBlock, position } = file; + const thumb = await getThumb(data, type, position); + const parts = []; + for (let pos = 0; pos < size; pos += ENCRYPTED_BLOCK_SIZE) { + const len = pos + ENCRYPTED_BLOCK_SIZE > size ? size - pos : ENCRYPTED_BLOCK_SIZE; + const { blockEncrypted, blockIv } = await getEncryptedBlock(pos, len); + const part = await axios.post(`${entry.baseUrl}blocks${entry.urlParams}`, blockEncrypted, { + headers: {'Content-Type': 'text/plain'}, + signal: entry.cancel.signal, + onUploadProgress: (ev) => { + const { loaded, total } = ev; + const partLoaded = pos + Math.floor(len * loaded / total); + entry.active = { loaded: partLoaded, total: size } + update(); + } + }); + parts.push({ blockIv, partId: part.data.assetId }); + } + entry.assets.push({ + encrypted: { type, thumb, parts } + }); + } + else if (file.type === 'image') { const formData = new FormData(); if (file.data.startsWith('file:')) { formData.append("asset", {uri: file.data, name: 'asset', type: 'application/octent-stream'}); @@ -142,7 +185,7 @@ async function upload(entry, update, complete) { formData.append("asset", {uri: 'file://' + file.data, name: 'asset', type: 'application/octent-stream'}); } let transform = encodeURIComponent(JSON.stringify(["ithumb;photo", "ilg;photo"])); - let asset = await axios.post(`${entry.url}&transforms=${transform}`, formData, { + let asset = await axios.post(`${entry.baseUrl}assets${entry.urlParams}&transforms=${transform}`, formData, { headers: { 'Content-Type': 'multipart/form-data' }, signal: entry.cancel.signal, onUploadProgress: (ev) => { @@ -168,7 +211,7 @@ async function upload(entry, update, complete) { } let thumb = 'vthumb;video;' + file.position; let transform = encodeURIComponent(JSON.stringify(["vlq;video", "vhd;video", thumb])); - let asset = await axios.post(`${entry.url}&transforms=${transform}`, formData, { + let asset = await axios.post(`${entry.baseUrl}assets${entry.urlParams}&transforms=${transform}`, formData, { headers: { 'Content-Type': 'multipart/form-data' }, signal: entry.cancel.signal, onUploadProgress: (ev) => { @@ -194,7 +237,7 @@ async function upload(entry, update, complete) { formData.append("asset", {uri: 'file://' + file.data, name: 'asset', type: 'application/octent-stream'}); } let transform = encodeURIComponent(JSON.stringify(["acopy;audio"])); - let asset = await axios.post(`${entry.url}&transforms=${transform}`, formData, { + let asset = await axios.post(`${entry.baseUrl}assets${entry.urlParams}&transforms=${transform}`, formData, { headers: { 'Content-Type': 'multipart/form-data' }, signal: entry.cancel.signal, onUploadProgress: (ev) => { diff --git a/app/mobile/src/package.json b/app/mobile/src/package.json index 0e71c241..0312d2f0 100644 --- a/app/mobile/src/package.json +++ b/app/mobile/src/package.json @@ -1,3 +1,6 @@ { - "name": "src" + "name": "src", + "dependencies": { + "react-native-fs": "^2.20.0" + } } diff --git a/app/mobile/src/session/conversation/addTopic/AddTopic.jsx b/app/mobile/src/session/conversation/addTopic/AddTopic.jsx index 67ba0c84..448be2d7 100644 --- a/app/mobile/src/session/conversation/addTopic/AddTopic.jsx +++ b/app/mobile/src/session/conversation/addTopic/AddTopic.jsx @@ -154,22 +154,22 @@ export function AddTopic({ contentKey, shareIntent, setShareIntent }) { blurOnSubmit={true} onSubmitEditing={sendMessage} returnKeyType="send" autoCapitalize="sentences" placeholder="New Message" multiline={true} /> - { !state.locked && state.enableImage && ( + { state.enableImage && ( )} - { !state.locked && state.enableVideo && ( + { state.enableVideo && ( )} - { !state.locked && state.enableAudio && ( + { state.enableAudio && ( )} - { !state.locked && ( + { (state.enableImage || state.enableVideo || state.enableAudio) && ( )} diff --git a/app/mobile/src/session/conversation/addTopic/useAddTopic.hook.js b/app/mobile/src/session/conversation/addTopic/useAddTopic.hook.js index 8d5bae84..aa15022a 100644 --- a/app/mobile/src/session/conversation/addTopic/useAddTopic.hook.js +++ b/app/mobile/src/session/conversation/addTopic/useAddTopic.hook.js @@ -3,8 +3,10 @@ import { UploadContext } from 'context/UploadContext'; import { ConversationContext } from 'context/ConversationContext'; import { Image } from 'react-native'; import Colors from 'constants/Colors'; -import { getChannelSeals, getContentKey, encryptTopicSubject } from 'context/sealUtil'; +import { encryptBlock, decryptBlock, getChannelSeals, getContentKey, encryptTopicSubject } from 'context/sealUtil'; import { AccountContext } from 'context/AccountContext'; +import RNFS from 'react-native-fs'; +import ImageResizer from '@bam.tech/react-native-image-resizer'; export function useAddTopic(contentKey) { @@ -37,9 +39,6 @@ export function useAddTopic(contentKey) { useEffect(() => { let conflict = false; - if (state.locked && state.assets.length > 0) { - conflict = true; - } state.assets.forEach(asset => { if (asset.type === 'image' && !state.enableImage) { conflict = true; @@ -54,6 +53,10 @@ export function useAddTopic(contentKey) { updateState({ conflict }); }, [state.assets, state.locked, state.enableImage, state.enableAudio, state.enableVideo]); + useEffect(() => { + updateState({ assets: [] }); + }, [contentKey]); + useEffect(() => { const cardId = conversation.state.card?.card?.cardId; const channelId = conversation.state.channel?.channelId; @@ -100,29 +103,55 @@ export function useAddTopic(contentKey) { updateState({ enableImage, enableAudio, enableVideo, locked }); }, [conversation.state]); + const setAsset = async (file, scale) => { + const url = file.startsWith('file:') ? file : `file://${file}`; + if (contentKey) { + const scaled = scale ? await scale(url) : url; + const stat = await RNFS.stat(scaled); + const getEncryptedBlock = async (pos, len) => { + if (pos + len > stat.size) { + return null; + } + const block = await RNFS.read(scaled, len, pos, 'base64'); + return encryptBlock(block, contentKey); + } + return { data: url, encrypted: true, size: stat.size, getEncryptedBlock }; + } + else { + return { data: url, encrypted: false }; + } + } + const actions = { setMessage: (message) => { updateState({ message }); }, - addImage: (data) => { - const url = data.startsWith('file:') ? data : 'file://' + data; - + addImage: async (data) => { assetId.current++; - Image.getSize(url, (width, height) => { - const asset = { key: assetId.current, type: 'image', data: url, ratio: width/height }; - updateState({ assets: [ ...state.assets, asset ] }); - }) - }, - addVideo: (data) => { - const url = data.startsWith('file:') ? data : 'file://' + data - assetId.current++; - const asset = { key: assetId.current, type: 'video', data: url, ratio: 1, duration: 0, position: 0 }; + const asset = await setAsset(data, async (file) => { + const scaled = await ImageResizer.createResizedImage(file, 512, 512, "JPEG", 90, 0, null); + return `file://${scaled.path}`; + }); + asset.key = assetId.current; + asset.type = 'image'; + asset.ratio = 1; updateState({ assets: [ ...state.assets, asset ] }); }, - addAudio: (data, label) => { - const url = data.startsWith('file:') ? data : 'file://' + data + addVideo: async (data) => { assetId.current++; - const asset = { key: assetId.current, type: 'audio', data: url, label }; + const asset = await setAsset(data); + asset.key = assetId.current; + asset.type = 'video'; + asset.position = 0; + asset.ratio = 1; + updateState({ assets: [ ...state.assets, asset ] }); + }, + addAudio: async (data, label) => { + assetId.current++; + const asset = await setAsset(data); + asset.key = assetId.current; + asset.type = 'audio'; + asset.label = label; updateState({ assets: [ ...state.assets, asset ] }); }, setVideoPosition: (key, position) => { @@ -181,24 +210,16 @@ export function useAddTopic(contentKey) { const assemble = (assets) => { if (!state.locked) { - if (assets?.length) { - return { - assets, - text: state.message, - textColor: state.colorSet ? state.color : null, - textSize: state.sizeSet ? state.size : null, - } - } - else { - return { - text: state.message, - textColor: state.colorSet ? state.color : null, - textSize: state.sizeSet ? state.size : null, - } + return { + assets: assets?.length ? assets : null, + text: state.message, + textColor: state.colorSet ? state.color : null, + textSize: state.sizeSet ? state.size : null, } } else { const message = { + assets: assets?.length ? assets : null, text: state.message, textColor: state.textColorSet ? state.textColor : null, textSize: state.textSizeSet ? state.textSize : null, diff --git a/app/mobile/src/session/conversation/topicItem/TopicItem.jsx b/app/mobile/src/session/conversation/topicItem/TopicItem.jsx index 1edf18a5..416a79e3 100644 --- a/app/mobile/src/session/conversation/topicItem/TopicItem.jsx +++ b/app/mobile/src/session/conversation/topicItem/TopicItem.jsx @@ -13,7 +13,7 @@ import { ImageAsset } from './imageAsset/ImageAsset'; import { AudioAsset } from './audioAsset/AudioAsset'; import { VideoAsset } from './videoAsset/VideoAsset'; import Carousel from 'react-native-reanimated-carousel'; - +import { GestureHandlerRootView } from 'react-native-gesture-handler'; export function TopicItem({ item, focused, focus, hosting, remove, update, block, report, contentKey }) { const { state, actions } = useTopicItem(item, hosting, remove, contentKey); @@ -109,34 +109,17 @@ export function TopicItem({ item, focused, focus, hosting, remove, update, block ); } - - const renderAsset = (asset) => { - return ( - - { asset.item.image && ( - - )} - { asset.item.video && ( - - )} - { asset.item.audio && ( - - )} - - ) - } - const renderThumb = (thumb) => { return ( - { thumb.item.image && ( - actions.showCarousel(thumb.index)} /> + { thumb.item.type === 'image' && ( + actions.showCarousel(thumb.index)} /> )} - { thumb.item.video && ( - actions.showCarousel(thumb.index)} /> + { thumb.item.type === 'video' && ( + actions.showCarousel(thumb.index)} /> )} - { thumb.item.audio && ( - actions.showCarousel(thumb.index)} /> + { thumb.item.type === 'audio' && ( + actions.showCarousel(thumb.index)} /> )} ); @@ -194,7 +177,7 @@ export function TopicItem({ item, focused, focus, hosting, remove, update, block { state.sharing && ( )} - { !state.sharing && ( + { !state.sharing && contentKey == null && ( @@ -229,26 +212,29 @@ export function TopicItem({ item, focused, focus, hosting, remove, update, block onRequestClose={actions.hideCarousel} > - ( - - { state.assets[index].image && ( - - )} - { state.assets[index].video && ( - - )} - { state.assets[index].audio && ( - - )} - - )} /> + + console.log('current index:', index)} + renderItem={({ index }) => ( + + { state.assets[index].type === 'image' && ( + + )} + { state.assets[index].type === 'video' && ( + + )} + { state.assets[index].type === 'audio' && ( + + )} + + )} /> + diff --git a/app/mobile/src/session/conversation/topicItem/audioAsset/AudioAsset.jsx b/app/mobile/src/session/conversation/topicItem/audioAsset/AudioAsset.jsx index b889a331..d912c7ad 100644 --- a/app/mobile/src/session/conversation/topicItem/audioAsset/AudioAsset.jsx +++ b/app/mobile/src/session/conversation/topicItem/audioAsset/AudioAsset.jsx @@ -1,5 +1,5 @@ -import { Image, View, Text, TouchableOpacity } from 'react-native'; -import { useRef } from 'react'; +import { ActivityIndicator, Image, View, Text, TouchableOpacity } from 'react-native'; +import { useEffect, useRef } from 'react'; import Colors from 'constants/Colors'; import Video from 'react-native-video'; import { useAudioAsset } from './useAudioAsset.hook'; @@ -8,9 +8,9 @@ import Icons from 'react-native-vector-icons/MaterialCommunityIcons'; import audio from 'images/audio.png'; import { useKeepAwake } from '@sayem314/react-native-keep-awake'; -export function AudioAsset({ topicId, asset, dismiss }) { +export function AudioAsset({ asset, dismiss }) { - const { state, actions } = useAudioAsset(topicId, asset); + const { state, actions } = useAudioAsset(asset); const player = useRef(null); @@ -37,6 +37,14 @@ export function AudioAsset({ topicId, asset, dismiss }) { ); } diff --git a/app/mobile/src/session/conversation/topicItem/audioAsset/AudioAsset.styled.js b/app/mobile/src/session/conversation/topicItem/audioAsset/AudioAsset.styled.js index 3bed3d2b..1b0d1c16 100644 --- a/app/mobile/src/session/conversation/topicItem/audioAsset/AudioAsset.styled.js +++ b/app/mobile/src/session/conversation/topicItem/audioAsset/AudioAsset.styled.js @@ -37,5 +37,18 @@ export const styles = StyleSheet.create({ player: { display: 'none', }, + loading: { + position: 'absolute', + display: 'flex', + flexAlign: 'center', + justifyContent: 'center', + flexDirection: 'column', + }, + decrypting: { + fontVariant: ["tabular-nums"], + paddingTop: 16, + fontSize: 12, + color: '#888888', + }, }) diff --git a/app/mobile/src/session/conversation/topicItem/audioAsset/useAudioAsset.hook.js b/app/mobile/src/session/conversation/topicItem/audioAsset/useAudioAsset.hook.js index 079e8766..66408083 100644 --- a/app/mobile/src/session/conversation/topicItem/audioAsset/useAudioAsset.hook.js +++ b/app/mobile/src/session/conversation/topicItem/audioAsset/useAudioAsset.hook.js @@ -3,7 +3,7 @@ import { ConversationContext } from 'context/ConversationContext'; import { Image } from 'react-native'; import { useWindowDimensions } from 'react-native'; -export function useAudioAsset(topicId, asset) { +export function useAudioAsset(asset) { const [state, setState] = useState({ width: 1, @@ -38,9 +38,13 @@ export function useAudioAsset(topicId, asset) { }, [dimensions]); useEffect(() => { - const url = conversation.actions.getTopicAssetUrl(topicId, asset.full); - updateState({ url }); - }, [topicId, conversation, asset]); + if (asset.encrypted) { + updateState({ url: asset.decrypted, failed: asset.error }); + } + else { + updateState({ url: asset.full }); + } + }, [asset]); const actions = { play: () => { diff --git a/app/mobile/src/session/conversation/topicItem/audioThumb/AudioThumb.jsx b/app/mobile/src/session/conversation/topicItem/audioThumb/AudioThumb.jsx index 03210f5d..1ab776cd 100644 --- a/app/mobile/src/session/conversation/topicItem/audioThumb/AudioThumb.jsx +++ b/app/mobile/src/session/conversation/topicItem/audioThumb/AudioThumb.jsx @@ -4,14 +4,14 @@ import { styles } from './AudioThumb.styled'; import Colors from 'constants/Colors'; import audio from 'images/audio.png'; -export function AudioThumb({ topicId, asset, onAssetView }) { +export function AudioThumb({ label, onAssetView }) { return ( - { asset.label && ( + { label && ( - { asset.label } + { label } )} diff --git a/app/mobile/src/session/conversation/topicItem/imageAsset/ImageAsset.jsx b/app/mobile/src/session/conversation/topicItem/imageAsset/ImageAsset.jsx index 82468daa..be3e1493 100644 --- a/app/mobile/src/session/conversation/topicItem/imageAsset/ImageAsset.jsx +++ b/app/mobile/src/session/conversation/topicItem/imageAsset/ImageAsset.jsx @@ -1,17 +1,22 @@ -import { View, Image, ActivityIndicator, TouchableOpacity } from 'react-native'; +import { Text, View, Image, ActivityIndicator, TouchableOpacity } from 'react-native'; import { useImageAsset } from './useImageAsset.hook'; import { styles } from './ImageAsset.styled'; import Colors from 'constants/Colors'; import Ionicons from 'react-native-vector-icons/AntDesign'; +import FastImage from 'react-native-fast-image' -export function ImageAsset({ topicId, asset, dismiss }) { - const { state, actions } = useImageAsset(topicId, asset); +export function ImageAsset({ asset, dismiss }) { + const { state, actions } = useImageAsset(asset); return ( + { state.url && ( - + )} { state.loaded && state.controls && ( @@ -28,9 +33,11 @@ export function ImageAsset({ topicId, asset, dismiss }) { { !state.loaded && !state.failed && ( + { asset.total > 1 && ( + { asset.block } / { asset.total } + )} )} ); } - diff --git a/app/mobile/src/session/conversation/topicItem/imageAsset/ImageAsset.styled.js b/app/mobile/src/session/conversation/topicItem/imageAsset/ImageAsset.styled.js index 36dfba0f..52f366a9 100644 --- a/app/mobile/src/session/conversation/topicItem/imageAsset/ImageAsset.styled.js +++ b/app/mobile/src/session/conversation/topicItem/imageAsset/ImageAsset.styled.js @@ -9,6 +9,16 @@ export const styles = StyleSheet.create({ }, loading: { position: 'absolute', + display: 'flex', + flexAlign: 'center', + justifyContent: 'center', + flexDirection: 'column', + }, + decrypting: { + fontVariant: ["tabular-nums"], + paddingTop: 16, + fontSize: 12, + color: '#dddddd', }, overlay: { marginRight: 16, @@ -21,6 +31,16 @@ export const styles = StyleSheet.create({ borderWidth: 1, borderColor: Colors.divider, }, + thumb: { + borderRadius: 4, + opacity: 0.3, + }, + main: { + borderRadius: 4, + position: 'absolute', + top: 0, + left: 0, + }, close: { position: 'absolute', opacity: 0.9, diff --git a/app/mobile/src/session/conversation/topicItem/imageAsset/useImageAsset.hook.js b/app/mobile/src/session/conversation/topicItem/imageAsset/useImageAsset.hook.js index f3967270..35b15e72 100644 --- a/app/mobile/src/session/conversation/topicItem/imageAsset/useImageAsset.hook.js +++ b/app/mobile/src/session/conversation/topicItem/imageAsset/useImageAsset.hook.js @@ -3,14 +3,14 @@ import { ConversationContext } from 'context/ConversationContext'; import { Image } from 'react-native'; import { useWindowDimensions } from 'react-native'; -export function useImageAsset(topicId, asset) { +export function useImageAsset(asset) { const [state, setState] = useState({ frameWidth: 1, frameHeight: 1, imageRatio: 1, - imageWidth: 1, - imageHeight: 1, + imageWidth: 1024, + imageHeight: 1024, url: null, loaded: false, failed: false, @@ -30,14 +30,15 @@ export function useImageAsset(topicId, asset) { const frameRatio = state.frameWidth / state.frameHeight; if (frameRatio > state.imageRatio) { //height constrained - const height = 0.9 * state.frameHeight; - const width = height * state.imageRatio; + const height = Math.floor(0.9 * state.frameHeight); + const width = Math.floor(height * state.imageRatio); + updateState({ imageWidth: width, imageHeight: height }); } else { //width constrained - const width = 0.9 * state.frameWidth; - const height = width / state.imageRatio; + const width = Math.floor(0.9 * state.frameWidth); + const height = Math.floor(width / state.imageRatio); updateState({ imageWidth: width, imageHeight: height }); } } @@ -45,20 +46,32 @@ export function useImageAsset(topicId, asset) { }, [state.frameWidth, state.frameHeight, state.imageRatio, state.loaded]); useEffect(() => { - updateState({ frameWidth: dimensions.width, frameHeight: dimensions.height }); + imageWidth = dimensions.width * 0.9 > state.imageWidth ? state.imageWidth : dimensions.width * 0.9; + imageHeight = dimensions.height * 0.9 > state.imageHeight ? state.imageHeight : dimensions.height * 0.9; + updateState({ frameWidth: dimensions.width, frameHeight: dimensions.height, imageWidth, imageHeight }); }, [dimensions]); useEffect(() => { - const url = conversation.actions.getTopicAssetUrl(topicId, asset.full); - updateState({ url }); - }, [topicId, conversation, asset]); + if (asset.encrypted) { + const now = Date.now(); + const url = asset.decrypted ? `file://${asset.decrypted}?now=${now}` : null + updateState({ url, failed: asset.error }); + } + else { + updateState({ url: asset.full, failed: false }); + } + }, [asset]); const actions = { - loaded: (e) => { - const { width, height } = e.nativeEvent.source; - updateState({ loaded: true, imageRatio: width / height }); + setRatio: (e) => { + const { width, height } = e.nativeEvent; + updateState({ imageRatio: width / height }); }, - failed: () => { + loaded: () => { + updateState({ loaded: true }); + }, + failed: (e) => { +console.log("FAILEE!!!", e); updateState({ failed: true }); }, showControls: () => { diff --git a/app/mobile/src/session/conversation/topicItem/imageThumb/ImageThumb.jsx b/app/mobile/src/session/conversation/topicItem/imageThumb/ImageThumb.jsx index 3786db6d..ee540ca6 100644 --- a/app/mobile/src/session/conversation/topicItem/imageThumb/ImageThumb.jsx +++ b/app/mobile/src/session/conversation/topicItem/imageThumb/ImageThumb.jsx @@ -4,12 +4,12 @@ import { useImageThumb } from './useImageThumb.hook'; import { styles } from './ImageThumb.styled'; import Colors from 'constants/Colors'; -export function ImageThumb({ topicId, asset, onAssetView }) { - const { state, actions } = useImageThumb(topicId, asset); +export function ImageThumb({ url, onAssetView }) { + const { state, actions } = useImageThumb(); return ( - ); diff --git a/app/mobile/src/session/conversation/topicItem/imageThumb/useImageThumb.hook.js b/app/mobile/src/session/conversation/topicItem/imageThumb/useImageThumb.hook.js index 44469f4d..147c84bd 100644 --- a/app/mobile/src/session/conversation/topicItem/imageThumb/useImageThumb.hook.js +++ b/app/mobile/src/session/conversation/topicItem/imageThumb/useImageThumb.hook.js @@ -2,7 +2,7 @@ import { useState, useRef, useEffect, useContext } from 'react'; import { ConversationContext } from 'context/ConversationContext'; import { Image } from 'react-native'; -export function useImageThumb(topicId, asset) { +export function useImageThumb() { const [state, setState] = useState({ loaded: false, @@ -16,11 +16,6 @@ export function useImageThumb(topicId, asset) { setState((s) => ({ ...s, ...value })); } - useEffect(() => { - const url = conversation.actions.getTopicAssetUrl(topicId, asset.thumb); - updateState({ url }); - }, [topicId, conversation, asset]); - const actions = { loaded: (e) => { const { width, height } = e.nativeEvent.source; diff --git a/app/mobile/src/session/conversation/topicItem/useTopicItem.hook.js b/app/mobile/src/session/conversation/topicItem/useTopicItem.hook.js index 23c2ae49..840f4634 100644 --- a/app/mobile/src/session/conversation/topicItem/useTopicItem.hook.js +++ b/app/mobile/src/session/conversation/topicItem/useTopicItem.hook.js @@ -1,4 +1,4 @@ -import { useState, useEffect, useContext } from 'react'; +import { useRef, useState, useEffect, useContext } from 'react'; import { Linking } from 'react-native'; import { ConversationContext } from 'context/ConversationContext'; import { CardContext } from 'context/CardContext'; @@ -8,10 +8,12 @@ import moment from 'moment'; import { useWindowDimensions, Text } from 'react-native'; import Colors from 'constants/Colors'; import { getCardByGuid } from 'context/cardUtil'; -import { decryptTopicSubject } from 'context/sealUtil'; +import { decryptBlock, decryptTopicSubject } from 'context/sealUtil'; import { sanitizeUrl } from '@braintree/sanitize-url'; import Share from 'react-native-share'; import RNFetchBlob from "rn-fetch-blob"; +import RNFS from 'react-native-fs'; +import { checkResponse, fetchWithTimeout } from 'api/fetchUtil'; export function useTopicItem(item, hosting, remove, contentKey) { @@ -42,6 +44,8 @@ export function useTopicItem(item, hosting, remove, contentKey) { const account = useContext(AccountContext); const dimensions = useWindowDimensions(); + const cancel = useRef(false); + const updateState = (value) => { setState((s) => ({ ...s, ...value })); } @@ -50,6 +54,43 @@ export function useTopicItem(item, hosting, remove, contentKey) { updateState({ width: dimensions.width, height: dimensions.height }); }, [dimensions]); + const setAssets = (parsed) => { + const assets = []; + if (parsed?.length) { + for (let i = 0; i < parsed.length; i++) { + const asset = parsed[i]; + if (asset.encrypted) { + const encrypted = true; + const { type, thumb, label, parts } = asset.encrypted; + assets.push({ type, thumb, label, encrypted, decrypted: null, parts }); + } + else { + const encrypted = false + if (asset.image) { + const type = 'image'; + const thumb = conversation.actions.getTopicAssetUrl(item.topicId, asset.image.thumb); + const full = conversation.actions.getTopicAssetUrl(item.topicId, asset.image.full); + assets.push({ type, thumb, encrypted, full }); + } + else if (asset.video) { + const type = 'video'; + const thumb = conversation.actions.getTopicAssetUrl(item.topicId, asset.video.thumb); + const lq = conversation.actions.getTopicAssetUrl(item.topicId, asset.video.lq); + const hd = conversation.actions.getTopicAssetUrl(item.topicId, asset.video.hd); + assets.push({ type, thumb, encrypted, lq, hd }); + } + else if (asset.audio) { + const type = 'audio'; + const label = asset.audio.label; + const full = conversation.actions.getTopicAssetUrl(item.topicId, asset.audio.full); + assets.push({ type, label, encrypted, full }); + } + } + }; + } + return assets; + } + useEffect(() => { const { topicId, revision, detail, unsealedDetail } = item; @@ -103,7 +144,7 @@ export function useTopicItem(item, hosting, remove, contentKey) { parsed = JSON.parse(data); message = parsed?.text; clickable = clickableText(parsed.text); - assets = parsed.assets; + assets = setAssets(parsed.assets); if (parsed.textSize === 'small') { fontSize = 10; } @@ -145,6 +186,7 @@ export function useTopicItem(item, hosting, remove, contentKey) { if (unsealed) { sealed = false; parsed = unsealed.message; + assets = setAssets(parsed.assets); message = parsed?.text; clickable = clickableText(parsed?.text); if (parsed?.textSize === 'small') { @@ -231,11 +273,55 @@ export function useTopicItem(item, hosting, remove, contentKey) { }; const actions = { - showCarousel: (index) => { - updateState({ carousel: true, carouselIndex: index }); + showCarousel: async (index) => { + const assets = state.assets.map((asset) => ({ ...asset, error: false, decrypted: null })); + updateState({ assets, carousel: true, carouselIndex: index }); + + try { + cancel.current = false; + const assets = state.assets; + for (let i = 0; i < assets.length; i++) { + const cur = (i + index) % assets.length + const asset = assets[cur]; + if (asset.encrypted) { + const ext = asset.type === 'video' ? '.mp4' : asset.type === 'audio' ? '.mp3' : ''; + const path = RNFS.DocumentDirectoryPath + `/${i}.asset${ext}`; + const exists = await RNFS.exists(path); + if (exists) { + RNFS.unlink(path); + } + assets[cur] = { ...asset, block: 0, total: asset.parts.length }; + updateState({ assets: [ ...assets ]}); + for (let j = 0; j < asset.parts.length; j++) { + const part = asset.parts[j]; + const url = conversation.actions.getTopicAssetUrl(item.topicId, part.partId); + const response = await fetchWithTimeout(url, { method: 'GET' }); + const block = await response.text(); + const decrypted = decryptBlock(block, part.blockIv, contentKey); + if (cancel.current) { + throw new Error("unseal assets cancelled"); + } + await RNFS.appendFile(path, decrypted, 'base64'); + + assets[cur] = { ...asset, block: j+1, total: asset.parts.length }; + updateState({ assets: [ ...assets ]}); + }; + + asset.decrypted = path; + assets[cur] = { ...asset }; + updateState({ assets: [ ...assets ]}); + }; + } + } + catch (err) { + console.log(err); + const assets = state.assets.map((asset) => ({ ...asset, error: true })); + updateState({ assets: [ ...assets ]}); + } }, hideCarousel: () => { updateState({ carousel: false }); + cancel.current = true; }, setActive: (activeId) => { updateState({ activeId }); diff --git a/app/mobile/src/session/conversation/topicItem/videoAsset/VideoAsset.jsx b/app/mobile/src/session/conversation/topicItem/videoAsset/VideoAsset.jsx index eece3cc8..c8d9a802 100644 --- a/app/mobile/src/session/conversation/topicItem/videoAsset/VideoAsset.jsx +++ b/app/mobile/src/session/conversation/topicItem/videoAsset/VideoAsset.jsx @@ -1,23 +1,29 @@ -import { ActivityIndicator, Image, View, TouchableOpacity } from 'react-native'; +import { ActivityIndicator, Image, Text, View, TouchableOpacity } from 'react-native'; import Colors from 'constants/Colors'; import Video from 'react-native-video'; import { useVideoAsset } from './useVideoAsset.hook'; import { styles } from './VideoAsset.styled'; import Icons from 'react-native-vector-icons/MaterialCommunityIcons'; import { useKeepAwake } from '@sayem314/react-native-keep-awake'; +import FastImage from 'react-native-fast-image' -export function VideoAsset({ topicId, asset, dismiss }) { +import { useEffect } from 'react'; - const { state, actions } = useVideoAsset(topicId, asset); +export function VideoAsset({ asset, dismiss }) { + + const { state, actions } = useVideoAsset(asset); useKeepAwake(); return ( + { state.url && ( - diff --git a/app/mobile/src/session/conversation/topicItem/videoAsset/VideoAsset.styled.js b/app/mobile/src/session/conversation/topicItem/videoAsset/VideoAsset.styled.js index 49720062..c77cb0d7 100644 --- a/app/mobile/src/session/conversation/topicItem/videoAsset/VideoAsset.styled.js +++ b/app/mobile/src/session/conversation/topicItem/videoAsset/VideoAsset.styled.js @@ -16,6 +16,13 @@ export const styles = StyleSheet.create({ paddingRight: 8, paddingTop: 4, }, + thumb: { + borderRadius: 4, + opacity: 0.6, + }, + main: { + position: 'absolute', + }, close: { position: 'absolute', top: 0, @@ -27,6 +34,16 @@ export const styles = StyleSheet.create({ }, loading: { position: 'absolute', + display: 'flex', + flexAlign: 'center', + justifyContent: 'center', + flexDirection: 'column', + }, + decrypting: { + fontVariant: ["tabular-nums"], + paddingTop: 16, + fontSize: 12, + color: '#dddddd', }, }) diff --git a/app/mobile/src/session/conversation/topicItem/videoAsset/useVideoAsset.hook.js b/app/mobile/src/session/conversation/topicItem/videoAsset/useVideoAsset.hook.js index 226c4279..62041d0a 100644 --- a/app/mobile/src/session/conversation/topicItem/videoAsset/useVideoAsset.hook.js +++ b/app/mobile/src/session/conversation/topicItem/videoAsset/useVideoAsset.hook.js @@ -3,13 +3,17 @@ import { ConversationContext } from 'context/ConversationContext'; import { Image } from 'react-native'; import { useWindowDimensions } from 'react-native'; -export function useVideoAsset(topicId, asset) { +export function useVideoAsset(asset) { const [state, setState] = useState({ frameWidth: 1, frameHeight: 1, videoRatio: 1, + thumbRatio: 1, width: 1, + height: 1, + thumbWidth: 64, + thumbHeight: 64, url: null, playing: false, loaded: false, @@ -27,6 +31,18 @@ export function useVideoAsset(topicId, asset) { useEffect(() => { const frameRatio = state.frameWidth / state.frameHeight; + if (frameRatio > state.thumbRatio) { + //thumbHeight constrained + const thumbHeight = 0.9 * state.frameHeight; + const thumbWidth = thumbHeight * state.thumbRatio; + updateState({ thumbWidth, thumbHeight }); + } + else { + //thumbWidth constrained + const thumbWidth = 0.9 * state.frameWidth; + const thumbHeight = thumbWidth / state.thumbRatio; + updateState({ thumbWidth, thumbHeight }); + } if (frameRatio > state.videoRatio) { //height constrained const height = 0.9 * state.frameHeight; @@ -39,18 +55,26 @@ export function useVideoAsset(topicId, asset) { const height = width / state.videoRatio; updateState({ width, height }); } - }, [state.frameWidth, state.frameHeight, state.videoRatio]); + }, [state.frameWidth, state.frameHeight, state.videoRatio, state.thumbRatio]); useEffect(() => { updateState({ frameWidth: dimensions.width, frameHeight: dimensions.height }); }, [dimensions]); useEffect(() => { - const url = conversation.actions.getTopicAssetUrl(topicId, asset.hd); - updateState({ url }); - }, [topicId, conversation, asset]); + if (asset.encrypted) { + updateState({ url: asset.decrypted, failed: asset.error }); + } + else { + updateState({ url: asset.hd }); + } + }, [asset]); const actions = { + setRatio: (e) => { + const { width, height } = e.nativeEvent; + updateState({ thumbRatio: width / height }); + }, setResolution: (width, height) => { updateState({ display: {}, videoRatio: width / height }); }, diff --git a/app/mobile/src/session/conversation/topicItem/videoThumb/VideoThumb.jsx b/app/mobile/src/session/conversation/topicItem/videoThumb/VideoThumb.jsx index ac3223dc..8c7b8dec 100644 --- a/app/mobile/src/session/conversation/topicItem/videoThumb/VideoThumb.jsx +++ b/app/mobile/src/session/conversation/topicItem/videoThumb/VideoThumb.jsx @@ -5,12 +5,13 @@ import { styles } from './VideoThumb.styled'; import Colors from 'constants/Colors'; import AntIcons from 'react-native-vector-icons/AntDesign'; -export function VideoThumb({ topicId, asset, onAssetView }) { - const { state, actions } = useVideoThumb(topicId, asset); +export function VideoThumb({ url, onAssetView }) { + const { state, actions } = useVideoThumb(); return ( - + diff --git a/app/mobile/src/session/conversation/topicItem/videoThumb/useVideoThumb.hook.js b/app/mobile/src/session/conversation/topicItem/videoThumb/useVideoThumb.hook.js index e069f859..99fe69da 100644 --- a/app/mobile/src/session/conversation/topicItem/videoThumb/useVideoThumb.hook.js +++ b/app/mobile/src/session/conversation/topicItem/videoThumb/useVideoThumb.hook.js @@ -2,11 +2,10 @@ import { useState, useRef, useEffect, useContext } from 'react'; import { ConversationContext } from 'context/ConversationContext'; import { Image } from 'react-native'; -export function useVideoThumb(topicId, asset) { +export function useVideoThumb() { const [state, setState] = useState({ ratio: 1, - url: null, }); const conversation = useContext(ConversationContext); @@ -15,16 +14,11 @@ export function useVideoThumb(topicId, asset) { setState((s) => ({ ...s, ...value })); } - useEffect(() => { - const url = conversation.actions.getTopicAssetUrl(topicId, asset.thumb); - if (url) { - Image.getSize(url, (width, height) => { - updateState({ url, ratio: width / height }); - }); - } - }, [topicId, conversation, asset]); - const actions = { + loaded: (e) => { + const { width, height } = e.nativeEvent.source; + updateState({ loaded: true, ratio: width / height }); + }, }; return { state, actions }; diff --git a/app/mobile/yarn.lock b/app/mobile/yarn.lock index 3f833abd..99fd9e80 100644 --- a/app/mobile/yarn.lock +++ b/app/mobile/yarn.lock @@ -1072,6 +1072,11 @@ "@babel/helper-validator-identifier" "^7.19.1" to-fast-properties "^2.0.0" +"@bam.tech/react-native-image-resizer@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@bam.tech/react-native-image-resizer/-/react-native-image-resizer-3.0.5.tgz#6661ba020de156268f73bdc92fbb93ef86f88a13" + integrity sha512-u5QGUQGGVZiVCJ786k9/kd7pPRZ6eYfJCYO18myVCH8FbVI7J8b5GT2Svjj2x808DlWeqfaZOOzxPqo27XYvrQ== + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz" @@ -2477,7 +2482,7 @@ balanced-match@^1.0.0: resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== -base-64@0.1.0: +base-64@0.1.0, base-64@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/base-64/-/base-64-0.1.0.tgz#780a99c84e7d600260361511c4877613bf24f6bb" integrity sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA== @@ -6305,6 +6310,11 @@ react-native-codegen@^0.71.5: jscodeshift "^0.13.1" nullthrows "^1.1.1" +react-native-create-thumbnail@^1.6.4: + version "1.6.4" + resolved "https://registry.yarnpkg.com/react-native-create-thumbnail/-/react-native-create-thumbnail-1.6.4.tgz#90f5b0a587de6e3738a7632fe3d9a9624ed83581" + integrity sha512-JWuKXswDXtqUPfuqh6rjCVMvTSSG3kUtwvSK/YdaNU0i+nZKxeqHmt/CO2+TyI/WSUFynGVmWT1xOHhCZAFsRQ== + react-native-device-info@^10.4.0: version "10.4.0" resolved "https://registry.npmjs.org/react-native-device-info/-/react-native-device-info-10.4.0.tgz" @@ -6322,6 +6332,19 @@ react-native-elevation@^1.0.0: resolved "https://registry.yarnpkg.com/react-native-elevation/-/react-native-elevation-1.0.0.tgz#2a091c688290ac9b08b5842d1a8e8a00fc84233e" integrity sha512-BWIKcEYtzjRV6GpkX0Km5/w2E7fgIcywiQOT7JZTc5NSbv/YI9kpFinB9lRFsOoRVGmiqq/O3VfP/oH2clIiBA== +react-native-fast-image@^8.6.3: + version "8.6.3" + resolved "https://registry.yarnpkg.com/react-native-fast-image/-/react-native-fast-image-8.6.3.tgz#6edc3f9190092a909d636d93eecbcc54a8822255" + integrity sha512-Sdw4ESidXCXOmQ9EcYguNY2swyoWmx53kym2zRsvi+VeFCHEdkO+WG1DK+6W81juot40bbfLNhkc63QnWtesNg== + +react-native-fs@^2.20.0: + version "2.20.0" + resolved "https://registry.yarnpkg.com/react-native-fs/-/react-native-fs-2.20.0.tgz#05a9362b473bfc0910772c0acbb73a78dbc810f6" + integrity sha512-VkTBzs7fIDUiy/XajOSNk0XazFE9l+QlMAce7lGuebZcag5CnjszB+u4BdqzwaQOdcYb5wsJIsqq4kxInIRpJQ== + dependencies: + base-64 "^0.1.0" + utf8 "^3.0.0" + react-native-gesture-handler@^2.9.0: version "2.9.0" resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.9.0.tgz#2f63812e523c646f25b9ad660fc6f75948e51241" @@ -7538,6 +7561,11 @@ use@^3.1.0: resolved "https://registry.npmjs.org/use/-/use-3.1.1.tgz" integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== +utf8@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/utf8/-/utf8-3.0.0.tgz#f052eed1364d696e769ef058b183df88c87f69d1" + integrity sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ== + util-deprecate@^1.0.1, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" diff --git a/doc/api.oa3 b/doc/api.oa3 index 1498501a..4af191c0 100644 --- a/doc/api.oa3 +++ b/doc/api.oa3 @@ -3248,6 +3248,52 @@ paths: type: string format: binary + /content/channels/{channelId}/topics/{topicId}/blocks: + post: + tags: + - content + description: Add a asset to the channel. Payload is a file block encoded as bas64 string. This is to support e2e as the client side will encrypt the file block before applying the base64 encoding. + operationId: add-channel-topic-block + security: + - bearerAuth: [] + parameters: + - name: channelId + in: path + description: specified channel id + required: true + schema: + type: string + - name: topicId + in: path + description: specified topic id + required: true + schema: + type: string + responses: + '201': + description: success + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Asset' + '401': + description: permission denied + '404': + description: channel not found + '406': + description: storage limit reached + '410': + description: account disabled + '500': + description: internal server error + requestBody: + content: + application/json: + schema: + type: string + /content/channels/{channelId}/topics/{topicId}/assets/{assetId}: get: tags: diff --git a/net/container/Dockerfile b/net/container/Dockerfile index 42732a60..3575dab1 100644 --- a/net/container/Dockerfile +++ b/net/container/Dockerfile @@ -36,7 +36,7 @@ RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then ARCHITECTURE=amd64; elif [ "$ && wget -P /app https://go.dev/dl/go1.17.5.linux-${ARCHITECTURE}.tar.gz \ && tar -C /usr/local -xzf /app/go1.17.5.linux-${ARCHITECTURE}.tar.gz -RUN git clone https://github.com/balzack/databag.git /app/databag +RUN git clone https://github.com/balzack/databag.git /app/databag RUN yarn config set network-timeout 300000 RUN yarn --cwd /app/databag/net/web install diff --git a/net/server/internal/api_addChannelTopicAsset.go b/net/server/internal/api_addChannelTopicAsset.go index 196f0f3d..f1396867 100644 --- a/net/server/internal/api_addChannelTopicAsset.go +++ b/net/server/internal/api_addChannelTopicAsset.go @@ -151,23 +151,6 @@ func AddChannelTopicAsset(w http.ResponseWriter, r *http.Request) { // invoke transcoder transcode() - // determine affected contact list - cards := make(map[string]store.Card) - for _, member := range channelSlot.Channel.Members { - cards[member.Card.GUID] = member.Card - } - for _, group := range channelSlot.Channel.Groups { - for _, card := range group.Cards { - cards[card.GUID] = card - } - } - - // notify - SetStatus(act) - for _, card := range cards { - SetContactChannelNotification(act, &card) - } - WriteResponse(w, &assets) } @@ -210,6 +193,10 @@ func saveAsset(src io.Reader, path string) (crc uint32, size int64, err error) { data := make([]byte, 4096) for { n, res := src.Read(data) + if n > 0 { + crc = crc32.Update(crc, table, data[:n]) + output.Write(data[:n]) + } if res != nil { if res == io.EOF { break @@ -217,9 +204,6 @@ func saveAsset(src io.Reader, path string) (crc uint32, size int64, err error) { err = res return } - - crc = crc32.Update(crc, table, data[:n]) - output.Write(data[:n]) } // read size diff --git a/net/server/internal/api_addChannelTopicBlock.go b/net/server/internal/api_addChannelTopicBlock.go new file mode 100644 index 00000000..6290f951 --- /dev/null +++ b/net/server/internal/api_addChannelTopicBlock.go @@ -0,0 +1,107 @@ +package databag + +import ( + "databag/internal/store" + "errors" + "github.com/google/uuid" + "github.com/gorilla/mux" + "gorm.io/gorm" + "net/http" +) + +//AddChannelTopicBlock adds a file block asset to a topic +func AddChannelTopicBlock(w http.ResponseWriter, r *http.Request) { + + // scan parameters + params := mux.Vars(r) + topicID := params["topicID"] + + channelSlot, guid, code, err := getChannelSlot(r, true) + if err != nil { + ErrResponse(w, code, err) + return + } + act := &channelSlot.Account + + // check storage + if full, err := isStorageFull(act); err != nil { + ErrResponse(w, http.StatusInternalServerError, err) + return + } else if full { + ErrResponse(w, http.StatusNotAcceptable, errors.New("storage limit reached")) + return + } + + // load topic + var topicSlot store.TopicSlot + if err = store.DB.Preload("Topic").Where("channel_id = ? AND topic_slot_id = ?", channelSlot.Channel.ID, topicID).First(&topicSlot).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + ErrResponse(w, http.StatusNotFound, err) + } else { + ErrResponse(w, http.StatusInternalServerError, err) + } + return + } + if topicSlot.Topic == nil { + ErrResponse(w, http.StatusNotFound, errors.New("referenced empty topic")) + return + } + + // can only update topic if creator + if topicSlot.Topic.GUID != guid { + ErrResponse(w, http.StatusUnauthorized, errors.New("topic not created by you")) + return + } + + // avoid async cleanup of file before record is created + garbageSync.Lock() + defer garbageSync.Unlock() + + // save new file + id := uuid.New().String() + path := getStrConfigValue(CNFAssetPath, APPDefaultPath) + "/" + channelSlot.Account.GUID + "/" + id + crc, size, err := saveAsset(r.Body, path) + if err != nil { + ErrResponse(w, http.StatusInternalServerError, err) + return + } + + asset := &store.Asset{} + asset.AssetID = id + asset.AccountID = channelSlot.Account.ID + asset.ChannelID = channelSlot.Channel.ID + asset.TopicID = topicSlot.Topic.ID + asset.Status = APPAssetReady + asset.Transform = APPTransformCopy + asset.TransformID = id + asset.Size = size + asset.Crc = crc + err = store.DB.Transaction(func(tx *gorm.DB) error { + if res := tx.Save(asset).Error; res != nil { + return res + } + if res := tx.Model(&topicSlot.Topic).Update("detail_revision", act.ChannelRevision+1).Error; res != nil { + return res + } + if res := tx.Model(&topicSlot).Update("revision", act.ChannelRevision+1).Error; res != nil { + return res + } + if res := tx.Model(&channelSlot.Channel).Update("topic_revision", act.ChannelRevision+1).Error; res != nil { + return res + } + if res := tx.Model(&channelSlot).Update("revision", act.ChannelRevision+1).Error; res != nil { + return res + } + if res := tx.Model(act).Update("channel_revision", act.ChannelRevision+1).Error; res != nil { + return res + } + return nil + }) + if err != nil { + ErrResponse(w, http.StatusInternalServerError, err) + return + } + + WriteResponse(w, &Asset{AssetID: asset.AssetID, Transform: "_", Status: APPAssetReady}) +} + diff --git a/net/server/internal/appValues.go b/net/server/internal/appValues.go index f0a6f66c..f96fdd8b 100644 --- a/net/server/internal/appValues.go +++ b/net/server/internal/appValues.go @@ -1,5 +1,8 @@ package databag +//APPCopyTransform reserved tranform code indicating copy +const APPTransformCopy = "_" + //APPTokenSize config for size of random access token const APPTokenSize = 16 diff --git a/net/server/internal/routers.go b/net/server/internal/routers.go index 8f855560..01d93f80 100644 --- a/net/server/internal/routers.go +++ b/net/server/internal/routers.go @@ -545,6 +545,13 @@ var endpoints = routes{ AddChannel, }, + route{ + "AddChannelTopicBlock", + strings.ToUpper("Post"), + "/content/channels/{channelID}/topics/{topicID}/blocks", + AddChannelTopicBlock, + }, + route{ "AddChannelTopicAsset", strings.ToUpper("Post"), diff --git a/net/web/package.json b/net/web/package.json index c493c3b2..226c2581 100644 --- a/net/web/package.json +++ b/net/web/package.json @@ -29,7 +29,7 @@ "react-dom": "^18.2.0", "react-easy-crop": "^4.1.4", "react-icons": "^4.8.0", - "react-player": "^2.10.0", + "react-image-file-resizer": "^0.4.8", "react-resize-detector": "^7.0.0", "react-router-dom": "^6.2.2", "react-scripts": "5.0.0", diff --git a/net/web/src/constants/Colors.js b/net/web/src/constants/Colors.js index fe048542..3c832d1c 100644 --- a/net/web/src/constants/Colors.js +++ b/net/web/src/constants/Colors.js @@ -7,6 +7,7 @@ const Colors = { formHover: '#efefef', grey: '#888888', white: '#ffffff', + black: '#000000', divider: '#dddddd', mask: '#dddddd', encircle: '#cccccc', diff --git a/net/web/src/context/sealUtil.js b/net/web/src/context/sealUtil.js index 32f83157..5483a501 100644 --- a/net/web/src/context/sealUtil.js +++ b/net/web/src/context/sealUtil.js @@ -56,6 +56,27 @@ export function updateChannelSubject(subject, contentKey) { return { subjectEncrypted, subjectIv }; } +export function encryptBlock(block, contentKey) { + const key = CryptoJS.enc.Hex.parse(contentKey); + const iv = CryptoJS.lib.WordArray.random(128 / 8); + const encrypted = CryptoJS.AES.encrypt(block, key, { iv: iv }); + const blockEncrypted = encrypted.ciphertext.toString(CryptoJS.enc.Base64) + const blockIv = iv.toString(); + + return { blockEncrypted, blockIv }; +} + +export function decryptBlock(blockEncrypted, blockIv, contentKey) { + const iv = CryptoJS.enc.Hex.parse(blockIv); + const key = CryptoJS.enc.Hex.parse(contentKey); + const enc = CryptoJS.enc.Base64.parse(blockEncrypted); + const cipher = CryptoJS.lib.CipherParams.create({ ciphertext: enc, iv: iv }); + const dec = CryptoJS.AES.decrypt(cipher, key, { iv: iv }); + const block = dec.toString(CryptoJS.enc.Utf8); + + return block; +} + export function decryptChannelSubject(subject, contentKey) { const { subjectEncrypted, subjectIv } = JSON.parse(subject); const iv = CryptoJS.enc.Hex.parse(subjectIv); diff --git a/net/web/src/context/useUploadContext.hook.js b/net/web/src/context/useUploadContext.hook.js index 9f2174e5..91f0e4c0 100644 --- a/net/web/src/context/useUploadContext.hook.js +++ b/net/web/src/context/useUploadContext.hook.js @@ -1,5 +1,8 @@ import { useState, useRef } from 'react'; import axios from 'axios'; +import Resizer from "react-image-file-resizer"; + +const ENCRYPTED_BLOCK_SIZE = (1024 * 1024); export function useUploadContext() { @@ -69,7 +72,8 @@ export function useUploadContext() { const controller = new AbortController(); const entry = { index: index.current, - url: `${host}/content/channels/${channelId}/topics/${topicId}/assets?contact=${token}`, + baseUrl: `${host}/content/channels/${channelId}/topics/${topicId}/`, + urlParams: `?contact=${token}`, files, assets: [], current: null, @@ -91,7 +95,8 @@ export function useUploadContext() { const controller = new AbortController(); const entry = { index: index.current, - url: `/content/channels/${channelId}/topics/${topicId}/assets?agent=${token}`, + baseUrl: `/content/channels/${channelId}/topics/${topicId}/`, + urlParams: `?agent=${token}`, files, assets: [], current: null, @@ -145,6 +150,63 @@ export function useUploadContext() { return { state, actions } } +function getImageThumb(data) { + return new Promise(resolve => { + Resizer.imageFileResizer(data, 192, 192, 'JPEG', 50, 0, + uri => { + resolve(uri); + }, 'base64', 128, 128 ); + }); +} + +function getVideoThumb(data, pos) { + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(data); + var video = document.createElement("video"); + var timeupdate = function (ev) { + video.removeEventListener("timeupdate", timeupdate); + video.pause(); + setTimeout(() => { + var canvas = document.createElement("canvas"); + if (video.videoWidth > video.videoHeight) { + canvas.width = 192; + canvas.height = Math.floor((192 * video.videoHeight / video.videoWidth)); + } + else { + canvas.height = 192; + canvas.width = Math.floor((192 * video.videoWidth / video.videoHeight)); + } + canvas.getContext("2d").drawImage(video, 0, 0, canvas.width, canvas.height); + var image = canvas.toDataURL("image/jpeg", 0.75); + resolve(image); + canvas.remove(); + video.remove(); + URL.revokeObjectURL(url); + }, 1000); + }; + video.addEventListener("timeupdate", timeupdate); + video.preload = "metadata"; + video.src = url; + video.muted = true; + video.playsInline = true; + video.currentTime = pos; + video.play(); + }); +} + +async function getThumb(data, type, position) { + + if (type === 'image') { + return await getImageThumb(data); + } + else if (type === 'video') { + return await getVideoThumb(data, position); + } + else { + return null; + } +} + async function upload(entry, update, complete) { if (!entry.files?.length) { entry.success(entry.assets); @@ -154,11 +216,35 @@ async function upload(entry, update, complete) { const file = entry.files.shift(); entry.active = {}; try { - if (file.image) { + if (file.encrypted) { + const { size, getEncryptedBlock, position, label, image, video, audio } = file; + const { data, type } = image ? { data: image, type: 'image' } : video ? { data: video, type: 'video' } : audio ? { data: audio, type: 'audio' } : {} + const thumb = await getThumb(data, type, position); + const parts = []; + for (let pos = 0; pos < size; pos += ENCRYPTED_BLOCK_SIZE) { + const len = pos + ENCRYPTED_BLOCK_SIZE > size ? size - pos : ENCRYPTED_BLOCK_SIZE; + const { blockEncrypted, blockIv } = await getEncryptedBlock(pos, len); + const part = await axios.post(`${entry.baseUrl}blocks${entry.urlParams}`, blockEncrypted, { + headers: {'Content-Type': 'text/plain'}, + signal: entry.cancel.signal, + onUploadProgress: (ev) => { + const { loaded, total } = ev; + const partLoaded = pos + Math.floor(len * loaded / total); + entry.active = { loaded: partLoaded, total: size } + update(); + } + }); + parts.push({ blockIv, partId: part.data.assetId }); + } + entry.assets.push({ + encrypted: { type, thumb, label, parts } + }); + } + else if (file.image) { const formData = new FormData(); formData.append('asset', file.image); let transform = encodeURIComponent(JSON.stringify(["ithumb;photo", "ilg;photo"])); - let asset = await axios.post(`${entry.url}&transforms=${transform}`, formData, { + let asset = await axios.post(`${entry.baseUrl}assets${entry.urlParams}&transforms=${transform}`, formData, { signal: entry.cancel.signal, onUploadProgress: (ev) => { const { loaded, total } = ev; @@ -178,7 +264,7 @@ async function upload(entry, update, complete) { formData.append('asset', file.video); let thumb = 'vthumb;video;' + file.position; let transform = encodeURIComponent(JSON.stringify(["vlq;video", "vhd;video", thumb])); - let asset = await axios.post(`${entry.url}&transforms=${transform}`, formData, { + let asset = await axios.post(`${entry.baseUrl}assets${entry.urlParams}&transforms=${transform}`, formData, { signal: entry.cancel.signal, onUploadProgress: (ev) => { const { loaded, total } = ev; @@ -198,7 +284,7 @@ async function upload(entry, update, complete) { const formData = new FormData(); formData.append('asset', file.audio); let transform = encodeURIComponent(JSON.stringify(["acopy;audio"])); - let asset = await axios.post(`${entry.url}&transforms=${transform}`, formData, { + let asset = await axios.post(`${entry.baseUrl}assets${entry.urlParams}&transforms=${transform}`, formData, { signal: entry.cancel.signal, onUploadProgress: (ev) => { const { loaded, total } = ev; diff --git a/net/web/src/session/conversation/Conversation.jsx b/net/web/src/session/conversation/Conversation.jsx index e14074b1..598b9fa3 100644 --- a/net/web/src/session/conversation/Conversation.jsx +++ b/net/web/src/session/conversation/Conversation.jsx @@ -14,7 +14,7 @@ export function Conversation({ closeConversation, openDetails, cardId, channelId const thread = useRef(null); const topicRenderer = (topic) => { - return ( actions.removeTopic(topic.id)} update={(text) => actions.updateTopic(topic, text)} sealed={state.sealed && !state.contentKey} diff --git a/net/web/src/session/conversation/addTopic/AddTopic.jsx b/net/web/src/session/conversation/addTopic/AddTopic.jsx index 52936eb4..9d1bc3f5 100644 --- a/net/web/src/session/conversation/addTopic/AddTopic.jsx +++ b/net/web/src/session/conversation/addTopic/AddTopic.jsx @@ -10,7 +10,7 @@ import { Carousel } from 'carousel/Carousel'; export function AddTopic({ contentKey }) { - const { state, actions } = useAddTopic(); + const { state, actions } = useAddTopic(contentKey); const [modal, modalContext] = Modal.useModal(); const attachImage = useRef(null); @@ -28,7 +28,7 @@ export function AddTopic({ contentKey }) { const addTopic = async () => { if (state.messageText || state.assets.length) { try { - await actions.addTopic(contentKey); + await actions.addTopic(); } catch (err) { console.log(err); @@ -108,22 +108,22 @@ export function AddTopic({ contentKey }) { value={state.messageText} autocapitalize="none" />
- { !contentKey && state.enableImage && ( + { state.enableImage && (
attachImage.current.click()}>
)} - { !contentKey && state.enableVideo && ( + { state.enableVideo && (
attachVideo.current.click()}>
)} - { !contentKey && state.enableAudio && ( + { state.enableAudio && (
attachAudio.current.click()}>
)} - { !contentKey && ( + { (state.enableImage || state.enableVideo || state.enableAudio) && (
)}
diff --git a/net/web/src/session/conversation/addTopic/useAddTopic.hook.js b/net/web/src/session/conversation/addTopic/useAddTopic.hook.js index e99b2a1d..b5c63553 100644 --- a/net/web/src/session/conversation/addTopic/useAddTopic.hook.js +++ b/net/web/src/session/conversation/addTopic/useAddTopic.hook.js @@ -1,8 +1,9 @@ -import { useContext, useState, useEffect } from 'react'; +import { useContext, useState, useRef, useEffect } from 'react'; import { ConversationContext } from 'context/ConversationContext'; -import { encryptTopicSubject } from 'context/sealUtil'; +import { encryptBlock, encryptTopicSubject } from 'context/sealUtil'; +import Resizer from "react-image-file-resizer"; -export function useAddTopic() { +export function useAddTopic(contentKey) { const [state, setState] = useState({ enableImage: null, @@ -18,6 +19,7 @@ export function useAddTopic() { }); const conversation = useContext(ConversationContext); + const objects = useRef([]); const updateState = (value) => { setState((s) => ({ ...s, ...value })); @@ -45,23 +47,79 @@ export function useAddTopic() { }); } + const clearObjects = () => { + objects.current.forEach(object => { + URL.revokeObjectURL(object); + }); + objects.current = []; + } + + useEffect(() => { + updateState({ assets: [] }); + return () => { clearObjects() }; + }, [contentKey]); + useEffect(() => { const { enableImage, enableAudio, enableVideo } = conversation.state.channel?.data?.channelDetail || {}; updateState({ enableImage, enableAudio, enableVideo }); }, [conversation.state.channel?.data?.channelDetail]); + const loadFileData = (file) => { + return new Promise(resolve => { + const reader = new FileReader() + reader.onloadend = (res) => { resolve(reader.result) } + reader.readAsArrayBuffer(file) + }) + }; + + const arrayBufferToBase64 = (buffer) => { + var binary = ''; + var bytes = new Uint8Array( buffer ); + var len = bytes.byteLength; + for (var i = 0; i < len; i++) { + binary += String.fromCharCode( bytes[ i ] ); + } + return window.btoa( binary ); + } + + const setUrl = async (file) => { + const url = URL.createObjectURL(file); + objects.current.push(url); + if (contentKey) { + const buffer = await loadFileData(file) + const getEncryptedBlock = (pos, len) => { + if (pos + len > buffer.byteLength) { + return null; + } + const slice = buffer.slice(pos, pos + len); + const block = arrayBufferToBase64(slice); + return encryptBlock(block, contentKey); + } + return { url, encrypted: true, size: buffer.byteLength, getEncryptedBlock }; + } + else { + return { url, encrypted: false }; + } + } + const actions = { - addImage: (image) => { - let url = URL.createObjectURL(image); - addAsset({ image, url }) + addImage: async (image) => { + const scaled = await getResizedImage(image); + const asset = await setUrl(scaled); + asset.image = image; + addAsset(asset); }, - addVideo: (video) => { - let url = URL.createObjectURL(video); - addAsset({ video, url, position: 0 }) + addVideo: async (video) => { + const asset = await setUrl(video); + asset.video = video; + asset.position = 0; + addAsset(asset); }, - addAudio: (audio) => { - let url = URL.createObjectURL(audio); - addAsset({ audio, url, label: '' }) + addAudio: async (audio) => { + const asset = await setUrl(audio); + asset.audio = audio; + asset.label = ''; + addAsset(asset); }, setLabel: (index, label) => { updateAsset(index, { label }); @@ -81,17 +139,15 @@ export function useAddTopic() { setTextSize: (value) => { updateState({ textSizeSet: true, textSize: value }); }, - addTopic: async (contentKey) => { + addTopic: async () => { if (!state.busy) { try { updateState({ busy: true }); const type = contentKey ? 'sealedtopic' : 'superbasictopic'; const message = (assets) => { if (contentKey) { - if (assets?.length) { - console.log('assets not yet supported on sealed channels'); - } const message = { + assets: assets?.length ? assets : null, text: state.messageText, textColor: state.textColorSet ? state.textColor : null, textSize: state.textSizeSet ? state.textSize : null, @@ -99,26 +155,18 @@ export function useAddTopic() { return encryptTopicSubject({ message }, contentKey); } else { - if (assets?.length) { - return { - assets, - text: state.messageText, - textColor: state.textColorSet ? state.textColor : null, - textSize: state.textSizeSet ? state.textSize : null, - } - } - else { - return { - text: state.messageText, - textColor: state.textColorSet ? state.textColor : null, - textSize: state.textSizeSet ? state.textSize : null, - } + return { + assets: assets?.length ? assets : null, + text: state.messageText, + textColor: state.textColorSet ? state.textColor : null, + textSize: state.textSizeSet ? state.textSize : null, } } }; - await conversation.actions.addTopic(type, message, state.assets); + await conversation.actions.addTopic(type, message, [ ...state.assets ]); updateState({ busy: false, messageText: null, textColor: '#444444', textColorSet: false, textSize: 12, textSizeSet: false, assets: [] }); + clearObjects(); } catch(err) { console.log(err); @@ -135,3 +183,18 @@ export function useAddTopic() { return { state, actions }; } +function getResizedImage(data) { + return new Promise(resolve => { + Resizer.imageFileResizer(data, 1024, 1024, 'JPEG', 90, 0, + uri => { + const base64 = uri.split(';base64,').pop(); + var binaryString = atob(base64); + var bytes = new Uint8Array(binaryString.length); + for (var i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + resolve(new Blob([bytes])); + }, 'base64', 256, 256 ); + }); +} + diff --git a/net/web/src/session/conversation/addTopic/videoFile/VideoFile.jsx b/net/web/src/session/conversation/addTopic/videoFile/VideoFile.jsx index d3e37c69..05ec82ef 100644 --- a/net/web/src/session/conversation/addTopic/videoFile/VideoFile.jsx +++ b/net/web/src/session/conversation/addTopic/videoFile/VideoFile.jsx @@ -1,5 +1,4 @@ import React, { useState, useRef } from 'react'; -import ReactPlayer from 'react-player' import ReactResizeDetector from 'react-resize-detector'; import { RightOutlined, LeftOutlined } from '@ant-design/icons'; import { VideoFileWrapper } from './VideoFile.styled'; @@ -17,22 +16,22 @@ export function VideoFile({ url, onPosition }) { const onSeek = (offset) => { if (player.current) { - let len = player.current.getDuration(); - if (len > 128) { - offset *= Math.floor(len / 128); + const len = player.current.duration; + if (len > 16) { + offset *= Math.floor(len / 16); } seek.current += offset; if (seek.current < 0 || seek.current >= len) { seek.current = 0; } onPosition(seek.current); - player.current.seekTo(seek.current, 'seconds'); - setPlaying(true); + player.current.currentTime = seek.current; + player.current.play(); } } const onPause = () => { - setPlaying(false); + player.current.pause(); } return ( @@ -42,8 +41,7 @@ export function VideoFile({ url, onPosition }) { if (width !== state.width || height !== state.height) { updateState({ width, height }); } - return onPause()} onPlay={() => onPause()} /> + return