Merge branch 'asset'

This commit is contained in:
balzack 2023-05-05 15:17:31 -07:00
commit 8b4664f90f
58 changed files with 1480 additions and 369 deletions

View File

@ -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",

View File

@ -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

View File

@ -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",

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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]);

View File

@ -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) => {

View File

@ -1,3 +1,6 @@
{
"name": "src"
"name": "src",
"dependencies": {
"react-native-fs": "^2.20.0"
}
}

View File

@ -154,22 +154,22 @@ export function AddTopic({ contentKey, shareIntent, setShareIntent }) {
blurOnSubmit={true} onSubmitEditing={sendMessage} returnKeyType="send"
autoCapitalize="sentences" placeholder="New Message" multiline={true} />
<View style={styles.addButtons}>
{ !state.locked && state.enableImage && (
{ state.enableImage && (
<TouchableOpacity style={styles.addButton} onPress={addImage}>
<AntIcons name="picture" size={20} color={Colors.text} />
</TouchableOpacity>
)}
{ !state.locked && state.enableVideo && (
{ state.enableVideo && (
<TouchableOpacity style={styles.addButton} onPress={addVideo}>
<MatIcons name="video-outline" size={24} color={Colors.text} />
</TouchableOpacity>
)}
{ !state.locked && state.enableAudio && (
{ state.enableAudio && (
<TouchableOpacity style={styles.addButton} onPress={addAudio}>
<MatIcons name="music-box-outline" size={20} color={Colors.text} />
</TouchableOpacity>
)}
{ !state.locked && (
{ (state.enableImage || state.enableVideo || state.enableAudio) && (
<View style={styles.divider} />
)}
<TouchableOpacity style={styles.addButton} onPress={actions.showFontSize}>

View File

@ -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,

View File

@ -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 (
<View style={styles.frame}>
{ asset.item.image && (
<ImageAsset topicId={item.topicId} asset={asset.item.image} dismiss={actions.hideCarousel} />
)}
{ asset.item.video && (
<VideoAsset topicId={item.topicId} asset={asset.item.video} dismiss={actions.hideCarousel} />
)}
{ asset.item.audio && (
<AudioAsset topicId={item.topicId} asset={asset.item.audio} dismiss={actions.hideCarousel} />
)}
</View>
)
}
const renderThumb = (thumb) => {
return (
<View>
{ thumb.item.image && (
<ImageThumb topicId={item.topicId} asset={thumb.item.image} onAssetView={() => actions.showCarousel(thumb.index)} />
{ thumb.item.type === 'image' && (
<ImageThumb url={thumb.item.thumb} onAssetView={() => actions.showCarousel(thumb.index)} />
)}
{ thumb.item.video && (
<VideoThumb topicId={item.topicId} asset={thumb.item.video} onAssetView={() => actions.showCarousel(thumb.index)} />
{ thumb.item.type === 'video' && (
<VideoThumb url={thumb.item.thumb} onAssetView={() => actions.showCarousel(thumb.index)} />
)}
{ thumb.item.audio && (
<AudioThumb topicId={item.topicId} asset={thumb.item.audio} onAssetView={() => actions.showCarousel(thumb.index)} />
{ thumb.item.type === 'audio' && (
<AudioThumb labe={thumb.item.label} onAssetView={() => actions.showCarousel(thumb.index)} />
)}
</View>
);
@ -194,7 +177,7 @@ export function TopicItem({ item, focused, focus, hosting, remove, update, block
{ state.sharing && (
<ActivityIndicator style={styles.share} color={Colors.white} size="small" />
)}
{ !state.sharing && (
{ !state.sharing && contentKey == null && (
<TouchableOpacity style={styles.share} onPress={shareMessage}>
<MatIcons name="share-variant-outline" size={18} color={Colors.white} />
</TouchableOpacity>
@ -229,26 +212,29 @@ export function TopicItem({ item, focused, focus, hosting, remove, update, block
onRequestClose={actions.hideCarousel}
>
<View style={styles.modal}>
<Carousel
loop
width={state.width}
autoPlay={false}
data={state.assets}
defaultIndex={state.carouselIndex}
scrollAnimationDuration={1000}
renderItem={({ index }) => (
<View style={styles.frame}>
{ state.assets[index].image && (
<ImageAsset topicId={item.topicId} asset={state.assets[index].image} dismiss={actions.hideCarousel} />
)}
{ state.assets[index].video && (
<VideoAsset topicId={item.topicId} asset={state.assets[index].video} dismiss={actions.hideCarousel} />
)}
{ state.assets[index].audio && (
<AudioAsset topicId={item.topicId} asset={state.assets[index].audio} dismiss={actions.hideCarousel} />
)}
</View>
)} />
<GestureHandlerRootView>
<Carousel
loop
width={state.width}
autoPlay={false}
data={state.assets}
defaultIndex={state.carouselIndex}
scrollAnimationDuration={1000}
onSnapToItem={(index) => console.log('current index:', index)}
renderItem={({ index }) => (
<View style={styles.frame}>
{ state.assets[index].type === 'image' && (
<ImageAsset asset={state.assets[index]} dismiss={actions.hideCarousel} />
)}
{ state.assets[index].type === 'video' && (
<VideoAsset asset={state.assets[index]} dismiss={actions.hideCarousel} />
)}
{ state.assets[index].type === 'audio' && (
<AudioAsset asset={state.assets[index]} dismiss={actions.hideCarousel} />
)}
</View>
)} />
</GestureHandlerRootView>
</View>
</Modal>
</View>

View File

@ -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 }) {
<Video ref={player} source={{ uri: state.url }} repeat={true}
paused={!state.playing} onLoad={actions.loaded} style={styles.player} />
)}
{ !state.loaded && (
<TouchableOpacity style={styles.loading} onPress={dismiss}>
<ActivityIndicator color={Colors.black} size="large" />
{ asset.total > 1 && (
<Text style={styles.decrypting}>{ asset.block } / { asset.total }</Text>
)}
</TouchableOpacity>
)}
</View>
);
}

View File

@ -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',
},
})

View File

@ -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: () => {

View File

@ -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 (
<TouchableOpacity activeOpacity={1} onPress={onAssetView}>
<Image source={audio} style={{ borderRadius: 4, width: 92, height: 92, marginRight: 16, backgroundColor: Colors.lightgrey }} resizeMode={'cover'} />
{ asset.label && (
{ label && (
<View style={styles.overlay}>
<Text style={styles.label}>{ asset.label }</Text>
<Text style={styles.label}>{ label }</Text>
</View>
)}
</TouchableOpacity>

View File

@ -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 (
<TouchableOpacity style={styles.container} activeOpacity={1} onPress={actions.showControls}>
<FastImage source={{ uri: asset.thumb }} onLoad={actions.setRatio}
style={{ ...styles.thumb, width: state.imageWidth, height: state.imageHeight }}
resizeMode={FastImage.resizeMode.contain} />
{ state.url && (
<Image source={{ uri: state.url }} onLoad={actions.loaded} onError={actions.failed}
style={{ borderRadius: 4, width: state.imageWidth, height: state.imageHeight }} resizeMode={'cover'} />
<FastImage source={{ uri: state.url }} onLoad={actions.loaded}
style={{ ...styles.main, width: state.imageWidth, height: state.imageHeight }}
resizeMode={FastImage.resizeMode.contain} />
)}
{ state.loaded && state.controls && (
@ -28,9 +33,11 @@ export function ImageAsset({ topicId, asset, dismiss }) {
{ !state.loaded && !state.failed && (
<TouchableOpacity style={styles.loading} onPress={dismiss}>
<ActivityIndicator color={Colors.white} size="large" />
{ asset.total > 1 && (
<Text style={styles.decrypting}>{ asset.block } / { asset.total }</Text>
)}
</TouchableOpacity>
)}
</TouchableOpacity>
);
}

View File

@ -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,

View File

@ -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: () => {

View File

@ -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 (
<TouchableOpacity activeOpacity={1} onPress={onAssetView}>
<Image source={{ uri: state.url }} style={{ opacity: state.loaded ? 1 : 0, borderRadius: 4, width: 92 * state.ratio, height: 92, marginRight: 16 }}
<Image source={{ uri: url }} style={{ opacity: state.loaded ? 1 : 0, borderRadius: 4, width: 92 * state.ratio, height: 92, marginRight: 16 }}
onLoad={actions.loaded} resizeMode={'cover'} />
</TouchableOpacity>
);

View File

@ -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;

View File

@ -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 });

View File

@ -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 (
<View style={styles.container}>
<TouchableOpacity activeOpacity={1} style={styles.container} onPress={actions.showControls}>
<FastImage source={{ uri: asset.thumb }} onLoad={actions.setRatio}
style={{ ...styles.thumb, width: state.thumbWidth, height: state.thumbHeight }}
resizeMode={FastImage.resizeMode.contain} />
{ state.url && (
<Video source={{ uri: state.url }} style={{ width: state.width, height: state.height }} resizeMode={'cover'}
onReadyForDisplay={(e) => { console.log(e) }}
<Video source={{ uri: state.url, type: 'video/mp4' }} style={{ ...styles.main, width: state.width, height: state.height }}
resizeMode={'cover'} onReadyForDisplay={(e) => { console.log(e) }}
onLoad={actions.loaded} repeat={true} paused={!state.playing} resizeMode="contain" />
)}
{ (!state.playing || state.controls) && (
@ -42,6 +48,9 @@ export function VideoAsset({ topicId, asset, dismiss }) {
{ !state.loaded && (
<TouchableOpacity style={styles.loading} onPress={dismiss}>
<ActivityIndicator color={Colors.white} size="large" />
{ asset.total > 0 && (
<Text style={styles.decrypting}>{ asset.block } / { asset.total }</Text>
)}
</TouchableOpacity>
)}
</View>

View File

@ -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',
},
})

View File

@ -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 });
},

View File

@ -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 (
<TouchableOpacity activeOpacity={1} onPress={onAssetView}>
<Image source={{ uri: state.url }} style={{ borderRadius: 4, width: 92 * state.ratio, height: 92, marginRight: 16 }} resizeMode={'cover'} />
<Image source={{ uri: url }} style={{ opacity: state.loaded ? 1 : 0, borderRadius: 4, width: 92 * state.ratio, height: 92, marginRight: 16 }}
onLoad={actions.loaded} resizeMode={'cover'} />
<View style={styles.overlay}>
<AntIcons name="caretright" size={20} color={Colors.white} />
</View>

View File

@ -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 };

View File

@ -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"

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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})
}

View File

@ -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

View File

@ -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"),

View File

@ -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",

View File

@ -7,6 +7,7 @@ const Colors = {
formHover: '#efefef',
grey: '#888888',
white: '#ffffff',
black: '#000000',
divider: '#dddddd',
mask: '#dddddd',
encircle: '#cccccc',

View File

@ -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);

View File

@ -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;

View File

@ -14,7 +14,7 @@ export function Conversation({ closeConversation, openDetails, cardId, channelId
const thread = useRef(null);
const topicRenderer = (topic) => {
return (<TopicItem host={cardId == null} topic={topic}
return (<TopicItem host={cardId == null} contentKey={state.contentKey} topic={topic}
remove={() => actions.removeTopic(topic.id)}
update={(text) => actions.updateTopic(topic, text)}
sealed={state.sealed && !state.contentKey}

View File

@ -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" />
</div>
<div class="buttons">
{ !contentKey && state.enableImage && (
{ state.enableImage && (
<div class="button space" onClick={() => attachImage.current.click()}>
<PictureOutlined />
</div>
)}
{ !contentKey && state.enableVideo && (
{ state.enableVideo && (
<div class="button space" onClick={() => attachVideo.current.click()}>
<VideoCameraOutlined />
</div>
)}
{ !contentKey && state.enableAudio && (
{ state.enableAudio && (
<div class="button space" onClick={() => attachAudio.current.click()}>
<SoundOutlined />
</div>
)}
{ !contentKey && (
{ (state.enableImage || state.enableVideo || state.enableAudio) && (
<div class="bar space" />
)}
<div class="button space">

View File

@ -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 );
});
}

View File

@ -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 <ReactPlayer ref={player} playing={playing} playbackRate={0} controls={false} height="100%" width="auto" url={url}
onStart={() => onPause()} onPlay={() => onPause()} />
return <video ref={player} muted onPlay={onPause} src={url} width={'auto'} height={'100%'} playsinline="true" />
}}
</ReactResizeDetector>
<div class="overlay" style={{ width: state.width, height: state.height }}>

View File

@ -8,10 +8,10 @@ import { ExclamationCircleOutlined, DeleteOutlined, EditOutlined, FireOutlined,
import { Carousel } from 'carousel/Carousel';
import { useTopicItem } from './useTopicItem.hook';
export function TopicItem({ host, sealed, topic, update, remove }) {
export function TopicItem({ host, contentKey, sealed, topic, update, remove }) {
const [ modal, modalContext ] = Modal.useModal();
const { state, actions } = useTopicItem();
const { state, actions } = useTopicItem(topic, contentKey);
const removeTopic = () => {
modal.confirm({
@ -52,16 +52,14 @@ export function TopicItem({ host, sealed, topic, update, remove }) {
};
const renderAsset = (asset, idx) => {
if (asset.image) {
return <ImageAsset thumbUrl={topic.assetUrl(asset.image.thumb, topic.id)}
fullUrl={topic.assetUrl(asset.image.full, topic.id)} />
if (asset.type === 'image') {
return <ImageAsset asset={asset} />
}
if (asset.video) {
return <VideoAsset thumbUrl={topic.assetUrl(asset.video.thumb, topic.id)}
lqUrl={topic.assetUrl(asset.video.lq, topic.id)} hdUrl={topic.assetUrl(asset.video.hd, topic.id)} />
if (asset.type === 'video') {
return <VideoAsset asset={asset} />
}
if (asset.audio) {
return <AudioAsset label={asset.audio.label} audioUrl={topic.assetUrl(asset.audio.full, topic.id)} />
if (asset.type === 'audio') {
return <AudioAsset asset={asset} />
}
return <></>
}
@ -113,7 +111,7 @@ export function TopicItem({ host, sealed, topic, update, remove }) {
)}
{ topic.transform === 'complete' && (
<div class="topic-assets">
<Carousel pad={40} items={topic.assets} itemRenderer={renderAsset} />
<Carousel pad={40} items={state.assets} itemRenderer={renderAsset} />
</div>
)}
</>

View File

@ -1,37 +1,21 @@
import React, { useEffect, useState, useRef } from 'react';
import { Spin } from 'antd';
import { Modal, Spin } from 'antd';
import ReactResizeDetector from 'react-resize-detector';
import { PlayCircleOutlined, MinusCircleOutlined, SoundOutlined } from '@ant-design/icons';
import { AudioAssetWrapper } from './AudioAsset.styled';
import { AudioAssetWrapper, AudioModalWrapper } from './AudioAsset.styled';
import { useAudioAsset } from './useAudioAsset.hook';
import background from 'images/audio.png';
export function AudioAsset({ label, audioUrl }) {
export function AudioAsset({ asset }) {
const [active, setActive] = useState(false);
const [width, setWidth] = useState(0);
const [ready, setReady] = useState(false);
const [playing, setPlaying] = useState(true);
const [url, setUrl] = useState(null);
const { actions, state } = useAudioAsset(asset);
const audio = useRef(null);
useEffect(() => {
setActive(false);
setReady(false);
setPlaying(true);
setUrl(null);
}, [label, audioUrl]);
const onActivate = () => {
setUrl(audioUrl);
setActive(true);
}
const onReady = () => {
setReady(true);
}
const play = (on) => {
setPlaying(on);
if (on) {
@ -54,32 +38,44 @@ export function AudioAsset({ label, audioUrl }) {
</ReactResizeDetector>
<div class="player" style={{ width: width, height: width }}>
<img class="background" src={background} alt="audio background" />
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{ !active && (
<div class="control" onClick={() => onActivate()}>
<SoundOutlined style={{ fontSize: 32, color: '#eeeeee', cursor: 'pointer' }} />
</div>
)}
{ active && !ready && (
<div class="control">
<Spin />
</div>
)}
{ active && ready && playing && (
<div class="control" onClick={() => play(false)}>
<MinusCircleOutlined style={{ fontSize: 32, color: '#eeeeee', cursor: 'pointer' }} />
</div>
)}
{ active && ready && !playing && (
<div class="control" onClick={() => play(true)}>
<PlayCircleOutlined style={{ fontSize: 32, color: '#eeeeee', cursor: 'pointer' }} />
</div>
)}
<audio style={{ position: 'absolute', top: 0, visibility: 'hidden' }} autoplay="true"
src={url} type="audio/mpeg" ref={audio} onPlay={onReady} />
<div class="control" onClick={actions.setActive}>
<SoundOutlined style={{ fontSize: 32, color: '#eeeeee', cursor: 'pointer' }} />
</div>
<div class="label">{ asset.label }</div>
</div>
<div class="label">{ label }</div>
<Modal centered={true} visible={state.active} width={256 + 12} bodyStyle={{ width: '100%', height: 'auto', paddingBottom: 6, paddingTop: 6, paddingLeft: 6, paddingRight: 6, backgroundColor: '#dddddd' }} footer={null} destroyOnClose={true} closable={false} onCancel={actions.clearActive}>
<audio style={{ position: 'absolute', top: 0, visibility: 'hidden' }} autoplay="true"
src={state.url} type="audio/mpeg" ref={audio} onPlay={actions.ready} />
<AudioModalWrapper>
<img class="background" src={background} alt="audio background" />
{ state.loading && state.error && (
<div class="failed">
<Spin size="large" delay={250} />
</div>
)}
{ state.loading && !state.error && (
<div class="loading">
<Spin size="large" delay={250} />
</div>
)}
{ !state.ready && !state.loading && (
<div class="loading">
<Spin size="large" delay={250} />
</div>
)}
{ state.ready && !state.loading && playing && (
<div class="control" onClick={() => play(false)}>
<MinusCircleOutlined style={{ fontSize: 64, color: '#eeeeee', cursor: 'pointer' }} />
</div>
)}
{ state.ready && !state.loading && !playing && (
<div class="control" onClick={() => play(true)}>
<PlayCircleOutlined style={{ fontSize: 64, color: '#eeeeee', cursor: 'pointer' }} />
</div>
)}
<div class="label">{ asset.label }</div>
</AudioModalWrapper>
</Modal>
</AudioAssetWrapper>
)
}

View File

@ -1,4 +1,5 @@
import styled from 'styled-components';
import Colors from 'constants/Colors';
export const AudioAssetWrapper = styled.div`
position: relative;
@ -41,3 +42,48 @@ export const AudioAssetWrapper = styled.div`
`;
export const AudioModalWrapper = styled.div`
width: 256px;
height: 256px;
display: flex;
align-items: center;
justify-content: center;
background-color: #aaaaaa;
.background {
width: 256px;
height: 256px;
}
.control {
position: absolute;
}
.label {
padding-top: 8px;
position: absolute;
top: 0;
font-size: 20px;
}
.failed {
position: absolute;
color: white;
border-radius: 8px;
.ant-spin-dot-item {
background-color: ${Colors.alert};
}
}
.loading {
position: absolute;
color: white;
border-radius: 8px;
.ant-spin-dot-item {
background-color: ${Colors.white};
}
}
`;

View File

@ -0,0 +1,55 @@
import { useState, useRef } from 'react';
export function useAudioAsset(asset) {
const revoke = useRef();
const index = useRef(0);
const [state, setState] = useState({
active: false,
loading: false,
error: false,
ready: false,
url: null,
});
const updateState = (value) => {
setState((s) => ({ ...s, ...value }));
}
const actions = {
setActive: async () => {
if (asset.encrypted) {
try {
const view = index.current;
updateState({ active: true, ready: false, error: false, loading: true, url: null });
const blob = await asset.getDecryptedBlob(() => view != index.current);
const url = URL.createObjectURL(blob);
revoke.current = url;
updateState({ loading: false, url });
}
catch (err) {
console.log(err);
updateState({ error: true });
}
}
else {
updateState({ active: true, loading: false, url: asset.full });
}
},
clearActive: () => {
index.current += 1;
updateState({ active: false, url: null });
if (revoke.current) {
URL.revokeObjectURL(revoke.current);
revoke.current = null;
}
},
ready: () => {
updateState({ ready: true });
}
};
return { state, actions };
}

View File

@ -1,12 +1,12 @@
import React, { useState } from 'react';
import { Modal } from 'antd';
import { Modal, Spin } from 'antd';
import ReactResizeDetector from 'react-resize-detector';
import { ImageAssetWrapper } from './ImageAsset.styled';
import { ImageAssetWrapper, ImageModalWrapper } from './ImageAsset.styled';
import { useImageAsset } from './useImageAsset.hook';
export function ImageAsset({ thumbUrl, fullUrl }) {
export function ImageAsset({ asset }) {
const { state, actions } = useImageAsset();
const { state, actions } = useImageAsset(asset);
const [dimension, setDimension] = useState({ width: 0, height: 0 });
const popout = () => {
@ -29,16 +29,31 @@ export function ImageAsset({ thumbUrl, fullUrl }) {
if (width !== dimension.width || height !== dimension.height) {
setDimension({ width, height });
}
return <img style={{ height: '100%', objectFit: 'contain' }} src={thumbUrl} alt="" />
return <img style={{ height: '100%', objectFit: 'contain' }} src={asset.thumb} alt="" />
}}
</ReactResizeDetector>
<div class="viewer">
<div class="overlay" style={{ width: dimension.width, height: dimension.height }}
onClick={popout} />
<Modal centered={true} visible={state.popout} width={state.width + 12} bodyStyle={{ width: '100%', height: 'auto', paddingBottom: 6, paddingTop: 6, paddingLeft: 6, paddingRight: 6, backgroundColor: '#dddddd' }} footer={null} destroyOnClose={true} closable={false} onCancel={actions.clearPopout}>
<div onClick={actions.clearPopout}>
<img style={{ width: '100%', objectFit: 'contain' }} src={fullUrl} alt="topic asset" />
</div>
<ImageModalWrapper onClick={actions.clearPopout}>
<div class="frame">
<img class="thumb" src={asset.thumb} alt="topic asset" />
{ !state.error && (
<div class="loading">
<Spin size="large" delay={250} />
</div>
)}
{ state.error && (
<div class="failed">
<Spin size="large" delay={250} />
</div>
)}
{ !state.loading && (
<img class="full" src={state.url} alt="topic asset" />
)}
</div>
</ImageModalWrapper>
</Modal>
</div>
</ImageAssetWrapper>

View File

@ -1,4 +1,5 @@
import styled from 'styled-components';
import Colors from 'constants/Colors';
export const ImageAssetWrapper = styled.div`
position: relative;
@ -40,3 +41,44 @@ export const ImageAssetWrapper = styled.div`
}
`;
export const ImageModalWrapper = styled.div`
.frame {
display: flex;
align-items: center;
justify-content: center;
background-color: ${Colors.black};
position: relative;
}
.thumb {
opacity: 0.5;
width: 100%;
object-fit: contain;
}
.full {
width: 100%;
object-fit: contain;
position: absolute;
}
.failed {
position: absolute;
color: white;
border-radius: 8px;
.ant-spin-dot-item {
background-color: ${Colors.alert};
}
}
.loading {
position: absolute;
color: white;
border-radius: 8px;
.ant-spin-dot-item {
background-color: ${Colors.white};
}
}
`;

View File

@ -1,11 +1,17 @@
import { useState } from 'react';
import { useState, useRef } from 'react';
export function useImageAsset() {
export function useImageAsset(asset) {
const revoke = useRef();
const index = useRef(0);
const [state, setState] = useState({
popout: false,
width: 0,
height: 0,
loading: false,
error: false,
url: null,
});
const updateState = (value) => {
@ -13,11 +19,32 @@ export function useImageAsset() {
}
const actions = {
setPopout: (width, height) => {
updateState({ popout: true, width, height });
setPopout: async (width, height) => {
if (asset.encrypted) {
try {
const view = index.current;
updateState({ popout: true, width, height, error: false, loading: true, url: null });
const blob = await asset.getDecryptedBlob(() => view != index.current);
const url = URL.createObjectURL(blob);
updateState({ loading: false, url });
revoke.current = url;
}
catch(err) {
console.log(err);
updateState({ error: true });
}
}
else {
updateState({ popout: true, width, height, loading: false, url: asset.full });
}
},
clearPopout: () => {
index.current += 1;
updateState({ popout: false });
if (revoke.current) {
URL.revokeObjectURL(revoke.current);
revoke.current = null;
}
},
};

View File

@ -1,16 +1,91 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { checkResponse, fetchWithTimeout } from 'api/fetchUtil';
import { decryptBlock } from 'context/sealUtil';
export function useTopicItem() {
export function useTopicItem(topic, contentKey) {
const [state, setState] = useState({
editing: false,
message: null,
assets: [],
});
const updateState = (value) => {
setState((s) => ({ ...s, ...value }));
}
const base64ToUint8Array = (base64) => {
var binaryString = atob(base64);
var bytes = new Uint8Array(binaryString.length);
for (var i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
useEffect(() => {
const assets = [];
if (topic.assets?.length) {
topic.assets.forEach(asset => {
if (asset.encrypted) {
const encrypted = true;
const { type, thumb, label, parts } = asset.encrypted;
const getDecryptedBlob = async (abort) => {
let pos = 0;
let len = 0;
const slices = []
for (let i = 0; i < parts.length; i++) {
if (abort()) {
throw new Error("asset unseal aborted");
}
const part = parts[i];
const url = topic.assetUrl(part.partId, topic.id);
const response = await fetchWithTimeout(url, { method: 'GET' });
const block = await response.text();
const decrypted = decryptBlock(block, part.blockIv, contentKey);
const slice = base64ToUint8Array(decrypted);
slices.push(slice);
len += slice.byteLength;
};
const data = new Uint8Array(len)
for (let i = 0; i < slices.length; i++) {
const slice = slices[i];
data.set(slice, pos);
pos += slice.byteLength
}
return new Blob([data]);
}
assets.push({ type, thumb, label, encrypted, getDecryptedBlob });
}
else {
const encrypted = false
if (asset.image) {
const type = 'image';
const thumb = topic.assetUrl(asset.image.thumb, topic.id);
const full = topic.assetUrl(asset.image.full, topic.id);
assets.push({ type, thumb, encrypted, full });
}
else if (asset.video) {
const type = 'video';
const thumb = topic.assetUrl(asset.video.thumb, topic.id);
const lq = topic.assetUrl(asset.video.lq, topic.id);
const hd = topic.assetUrl(asset.video.hd, topic.id);
assets.push({ type, thumb, encrypted, lq, hd });
}
else if (asset.audio) {
const type = 'audio';
const label = asset.audio.label;
const full = topic.assetUrl(asset.audio.full, topic.id);
assets.push({ type, label, encrypted, full });
}
}
});
updateState({ assets });
}
}, [topic.assets]);
const actions = {
setEditing: (message) => {
updateState({ editing: true, message });

View File

@ -1,12 +1,12 @@
import { Modal } from 'antd';
import { Modal, Spin } from 'antd';
import ReactResizeDetector from 'react-resize-detector';
import { VideoCameraOutlined } from '@ant-design/icons';
import { VideoAssetWrapper } from './VideoAsset.styled';
import { VideoAssetWrapper, VideoModalWrapper } from './VideoAsset.styled';
import { useVideoAsset } from './useVideoAsset.hook';
export function VideoAsset({ thumbUrl, lqUrl, hdUrl }) {
export function VideoAsset({ asset }) {
const { state, actions } = useVideoAsset();
const { state, actions } = useVideoAsset(asset);
const activate = () => {
if (state.dimension.width / state.dimension.height > window.innerWidth / window.innerHeight) {
@ -28,7 +28,7 @@ export function VideoAsset({ thumbUrl, lqUrl, hdUrl }) {
if (width !== state.dimension.width || height !== state.dimension.height) {
actions.setDimension({ width, height });
}
return <img style={{ height: '100%', objectFit: 'contain' }} src={thumbUrl} alt="" />
return <img style={{ height: '100%', objectFit: 'contain' }} src={asset.thumb} alt="" />
}}
</ReactResizeDetector>
<div class="overlay" style={{ width: state.dimension.width, height: state.dimension.height }}>
@ -38,8 +38,29 @@ export function VideoAsset({ thumbUrl, lqUrl, hdUrl }) {
</div>
)}
<Modal centered={true} style={{ backgroundColor: '#aacc00', padding: 0 }} visible={state.active} width={state.width + 12} bodyStyle={{ paddingBottom: 0, paddingTop: 6, paddingLeft: 6, paddingRight: 6, backgroundColor: '#dddddd', margin: 0 }} footer={null} destroyOnClose={true} closable={false} onCancel={actions.clearActive}>
<video autoplay="true" controls src={hdUrl} width={state.width} height={state.height}
playsinline="true" />
<VideoModalWrapper>
<div class="wrapper">
{ !state.loaded && (
<div class="frame">
<img class="thumb" src={asset.thumb} alt="topic asset" />
{ state.error && (
<div class="failed">
<Spin size="large" delay={250} />
</div>
)}
{ !state.error && (
<div class="loading">
<Spin size="large" delay={250} />
</div>
)}
</div>
)}
{ !state.loading && (
<video style={{display: state.loaded ? 'block' : 'none'}} autoplay="true" controls src={state.url} width={state.width} height={state.height}
playsinline="true" onLoadedData={actions.setLoaded} />
)}
</div>
</VideoModalWrapper>
</Modal>
</div>
</VideoAssetWrapper>

View File

@ -1,4 +1,5 @@
import styled from 'styled-components';
import Colors from 'constants/Colors';
export const VideoAssetWrapper = styled.div`
position: relative;
@ -14,3 +15,42 @@ export const VideoAssetWrapper = styled.div`
}
`;
export const VideoModalWrapper = styled.div`
.wrapper {
padding-bottom: 6px;
}
.frame {
display: flex;
align-items: center;
justify-content: center;
background-color: black;
}
.thumb {
opacity: 0.3;
width: 100%;
object-fit: contain;
}
.failed {
position: absolute;
color: white;
border-radius: 8px;
.ant-spin-dot-item {
background-color: ${Colors.alert};
}
}
.loading {
position: absolute;
color: white;
border-radius: 8px;
.ant-spin-dot-item {
background-color: ${Colors.white};
}
}
`;

View File

@ -1,12 +1,19 @@
import { useState } from 'react';
import { useState, useRef } from 'react';
export function useVideoAsset() {
export function useVideoAsset(asset) {
const revoke = useRef();
const index = useRef(0);
const [state, setState] = useState({
width: 0,
height: 0,
active: false,
dimension: { width: 0, height: 0 },
loading: false,
error: false,
url: null,
loaded: false,
});
const updateState = (value) => {
@ -14,15 +21,39 @@ export function useVideoAsset() {
}
const actions = {
setActive: (width, height, url) => {
updateState({ active: true, width, height });
setActive: async (width, height) => {
if (asset.encrypted) {
try {
const view = index.current;
updateState({ active: true, width, height, error: false, loaded: false, loading: true, url: null });
const blob = await asset.getDecryptedBlob(() => view != index.current);
const url = URL.createObjectURL(blob);
revoke.current = url;
updateState({ url, loading: false });
}
catch (err) {
console.log(err);
updateState({ error: true });
}
}
else {
updateState({ active: true, width, height, loading: false, url: asset.hd });
}
},
clearActive: () => {
index.current += 1;
updateState({ active: false });
if (revoke.current) {
URL.revokeObjectURL(revoke.current);
revoke.current = null;
}
},
setDimension: (dimension) => {
updateState({ dimension });
},
setLoaded: () => {
updateState({ loaded: true });
},
};
return { state, actions };

View File

@ -145,7 +145,7 @@ export function useConversation(cardId, channelId) {
let group = '';
let clickable = [];
const words = text == null ? '' : DOMPurify.sanitize(text).split(' ');
const words = text == [] ? '' : DOMPurify.sanitize(text).split(' ');
words.forEach((word, index) => {
if (!!urlPattern.test(word)) {
clickable.push(<span key={index}>{ group }</span>);

View File

@ -167,7 +167,6 @@ export function useSession() {
await ring.actions.decline(cardId, contactNode, contactToken, callId);
},
accept: async (call) => {
console.log("ACCEPTING:", call);
const { cardId, callId, contactNode, contactToken, calleeToken, iceUrl, iceUsername, icePassword } = call;
await ring.actions.accept(cardId, callId, contactNode, contactToken, calleeToken, iceUrl, iceUsername, icePassword);
},

View File

@ -4171,7 +4171,7 @@ deep-is@^0.1.3, deep-is@~0.1.3:
resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz"
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
deepmerge@^4.0.0, deepmerge@^4.2.2:
deepmerge@^4.2.2:
version "4.2.2"
resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz"
integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==
@ -7162,11 +7162,6 @@ lines-and-columns@^1.1.6:
resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
load-script@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/load-script/-/load-script-1.0.0.tgz"
integrity sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==
loader-runner@^4.2.0:
version "4.3.0"
resolved "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz"
@ -7421,11 +7416,6 @@ memfs@^3.1.2, memfs@^3.4.3:
dependencies:
fs-monkey "^1.0.3"
memoize-one@^5.1.1:
version "5.2.1"
resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz"
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
merge-descriptors@1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz"
@ -8600,7 +8590,7 @@ prompts@^2.0.1, prompts@^2.4.2:
kleur "^3.0.3"
sisteransi "^1.0.5"
prop-types@^15.5.10, prop-types@^15.7.2, prop-types@^15.8.1:
prop-types@^15.5.10, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@ -9129,16 +9119,16 @@ react-error-overlay@^6.0.11:
resolved "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz"
integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==
react-fast-compare@^3.0.1:
version "3.2.0"
resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz"
integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==
react-icons@^4.8.0:
version "4.8.0"
resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.8.0.tgz#621e900caa23b912f737e41be57f27f6b2bff445"
integrity sha512-N6+kOLcihDiAnj5Czu637waJqSnwlMNROzVZMhfX68V/9bu9qHaMIJC4UdozWoOk57gahFCNHwVvWzm0MTzRjg==
react-image-file-resizer@^0.4.8:
version "0.4.8"
resolved "https://registry.yarnpkg.com/react-image-file-resizer/-/react-image-file-resizer-0.4.8.tgz#85f4ae4469fd2867d961568af660ef403d7a79af"
integrity sha512-Ue7CfKnSlsfJ//SKzxNMz8avDgDSpWQDOnTKOp/GNRFJv4dO9L5YGHNEnj40peWkXXAK2OK0eRIoXhOYpUzUTQ==
react-is@^16.12.0, "react-is@^16.12.0 || ^17.0.0 || ^18.0.0", react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1"
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
@ -9154,17 +9144,6 @@ react-is@^18.0.0, react-is@^18.2.0:
resolved "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz"
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
react-player@^2.10.0:
version "2.11.0"
resolved "https://registry.npmjs.org/react-player/-/react-player-2.11.0.tgz"
integrity sha512-fIrwpuXOBXdEg1FiyV9isKevZOaaIsAAtZy5fcjkQK9Nhmk1I2NXzY/hkPos8V0zb/ZX416LFy8gv7l/1k3a5w==
dependencies:
deepmerge "^4.0.0"
load-script "^1.0.0"
memoize-one "^5.1.1"
prop-types "^15.7.2"
react-fast-compare "^3.0.1"
react-refresh@^0.11.0:
version "0.11.0"
resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz"