mirror of
https://github.com/balzack/databag.git
synced 2025-02-12 03:29:16 +00:00
Merge branch 'asset'
This commit is contained in:
commit
8b4664f90f
@ -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",
|
||||
|
@ -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
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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,6 +114,20 @@ export function useConversationContext() {
|
||||
updateState({ loaded: true, offsync: false, topics: topics.current, card: cardValue, channel: channelValue });
|
||||
}
|
||||
else if (loadMore) {
|
||||
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);
|
||||
@ -101,6 +135,7 @@ export function useConversationContext() {
|
||||
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);
|
||||
if (topicRevision > delta.revision) {
|
||||
@ -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);
|
||||
|
@ -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]);
|
||||
@ -331,9 +373,28 @@ export function useStoreContext() {
|
||||
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]);
|
||||
|
@ -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) => {
|
||||
|
@ -1,3 +1,6 @@
|
||||
{
|
||||
"name": "src"
|
||||
"name": "src",
|
||||
"dependencies": {
|
||||
"react-native-fs": "^2.20.0"
|
||||
}
|
||||
}
|
||||
|
@ -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}>
|
||||
|
@ -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,
|
||||
assets: assets?.length ? assets : null,
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
const message = {
|
||||
assets: assets?.length ? assets : null,
|
||||
text: state.message,
|
||||
textColor: state.textColorSet ? state.textColor : null,
|
||||
textSize: state.textSizeSet ? state.textSize : null,
|
||||
|
@ -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,6 +212,7 @@ export function TopicItem({ item, focused, focus, hosting, remove, update, block
|
||||
onRequestClose={actions.hideCarousel}
|
||||
>
|
||||
<View style={styles.modal}>
|
||||
<GestureHandlerRootView>
|
||||
<Carousel
|
||||
loop
|
||||
width={state.width}
|
||||
@ -236,19 +220,21 @@ export function TopicItem({ item, focused, focus, hosting, remove, update, block
|
||||
data={state.assets}
|
||||
defaultIndex={state.carouselIndex}
|
||||
scrollAnimationDuration={1000}
|
||||
onSnapToItem={(index) => console.log('current index:', index)}
|
||||
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].type === 'image' && (
|
||||
<ImageAsset asset={state.assets[index]} dismiss={actions.hideCarousel} />
|
||||
)}
|
||||
{ state.assets[index].video && (
|
||||
<VideoAsset topicId={item.topicId} asset={state.assets[index].video} dismiss={actions.hideCarousel} />
|
||||
{ state.assets[index].type === 'video' && (
|
||||
<VideoAsset asset={state.assets[index]} dismiss={actions.hideCarousel} />
|
||||
)}
|
||||
{ state.assets[index].audio && (
|
||||
<AudioAsset topicId={item.topicId} asset={state.assets[index].audio} dismiss={actions.hideCarousel} />
|
||||
{ state.assets[index].type === 'audio' && (
|
||||
<AudioAsset asset={state.assets[index]} dismiss={actions.hideCarousel} />
|
||||
)}
|
||||
</View>
|
||||
)} />
|
||||
</GestureHandlerRootView>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -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: () => {
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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: () => {
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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;
|
||||
|
@ -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 });
|
||||
|
@ -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>
|
||||
|
@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -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 });
|
||||
},
|
||||
|
@ -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>
|
||||
|
@ -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 };
|
||||
|
@ -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"
|
||||
|
46
doc/api.oa3
46
doc/api.oa3
@ -3248,6 +3248,52 @@ paths:
|
||||
type: string
|
||||
format: binary
|
||||
|
||||
/content/channels/{channelId}/topics/{topicId}/blocks:
|
||||
post:
|
||||
tags:
|
||||
- content
|
||||
description: Add a asset to the channel. Payload is a file block encoded as bas64 string. This is to support e2e as the client side will encrypt the file block before applying the base64 encoding.
|
||||
operationId: add-channel-topic-block
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: channelId
|
||||
in: path
|
||||
description: specified channel id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: topicId
|
||||
in: path
|
||||
description: specified topic id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'201':
|
||||
description: success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Asset'
|
||||
'401':
|
||||
description: permission denied
|
||||
'404':
|
||||
description: channel not found
|
||||
'406':
|
||||
description: storage limit reached
|
||||
'410':
|
||||
description: account disabled
|
||||
'500':
|
||||
description: internal server error
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/content/channels/{channelId}/topics/{topicId}/assets/{assetId}:
|
||||
get:
|
||||
tags:
|
||||
|
@ -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
|
||||
|
107
net/server/internal/api_addChannelTopicBlock.go
Normal file
107
net/server/internal/api_addChannelTopicBlock.go
Normal 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})
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
|
@ -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"),
|
||||
|
@ -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",
|
||||
|
@ -7,6 +7,7 @@ const Colors = {
|
||||
formHover: '#efefef',
|
||||
grey: '#888888',
|
||||
white: '#ffffff',
|
||||
black: '#000000',
|
||||
divider: '#dddddd',
|
||||
mask: '#dddddd',
|
||||
encircle: '#cccccc',
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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}
|
||||
|
@ -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">
|
||||
|
@ -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,
|
||||
assets: assets?.length ? assets : null,
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
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 );
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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 }}>
|
||||
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
@ -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()}>
|
||||
<div class="control" onClick={actions.setActive}>
|
||||
<SoundOutlined style={{ fontSize: 32, color: '#eeeeee', cursor: 'pointer' }} />
|
||||
</div>
|
||||
)}
|
||||
{ active && !ready && (
|
||||
<div class="control">
|
||||
<Spin />
|
||||
<div class="label">{ asset.label }</div>
|
||||
</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>
|
||||
)}
|
||||
<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={url} type="audio/mpeg" ref={audio} onPlay={onReady} />
|
||||
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>
|
||||
<div class="label">{ label }</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>
|
||||
)
|
||||
}
|
||||
|
@ -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};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
@ -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 };
|
||||
}
|
||||
|
@ -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" />
|
||||
<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>
|
||||
|
@ -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};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -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 });
|
||||
|
@ -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>
|
||||
|
@ -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};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
@ -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 };
|
||||
|
@ -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>);
|
||||
|
@ -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);
|
||||
},
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user