From 446b974aed1a00081956a446a48a0e5c9e32dfcf Mon Sep 17 00:00:00 2001 From: balzack Date: Thu, 6 Oct 2022 22:52:28 -0700 Subject: [PATCH] support uploading assets --- app/mobile/App.js | 51 +++-- app/mobile/package.json | 1 + app/mobile/src/context/UploadContext.js | 14 ++ app/mobile/src/context/useCardContext.hook.js | 24 +- .../src/context/useChannelContext.hook.js | 20 +- .../context/useConversationContext.hook.js | 24 +- .../src/context/useUploadContext.hook.js | 216 ++++++++++++++++++ app/mobile/yarn.lock | 28 +++ 8 files changed, 337 insertions(+), 41 deletions(-) create mode 100644 app/mobile/src/context/UploadContext.js create mode 100644 app/mobile/src/context/useUploadContext.hook.js diff --git a/app/mobile/App.js b/app/mobile/App.js index 78dc3113..3a8f3215 100644 --- a/app/mobile/App.js +++ b/app/mobile/App.js @@ -7,6 +7,7 @@ import { Access } from 'src/access/Access'; import { Session } from 'src/session/Session'; import { Admin } from 'src/admin/Admin'; import { StoreContextProvider } from 'context/StoreContext'; +import { UploadContextProvider } from 'context/UploadContext'; import { AppContextProvider } from 'context/AppContext'; import { AccountContextProvider } from 'context/AccountContext'; import { ProfileContextProvider } from 'context/ProfileContext'; @@ -24,30 +25,32 @@ export default function App() { return ( - - - - - - - - - - } /> - } /> - } /> - } /> - } /> - } /> - - - - - - - - - + + + + + + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + + + + + ); } diff --git a/app/mobile/package.json b/app/mobile/package.json index 53963e64..255df7e9 100644 --- a/app/mobile/package.json +++ b/app/mobile/package.json @@ -15,6 +15,7 @@ "@react-navigation/native": "^6.0.13", "@react-navigation/stack": "^6.3.0", "@stream-io/flat-list-mvcp": "^0.10.2", + "axios": "^1.1.0", "expo": "~46.0.9", "expo-av": "^12.0.4", "expo-splash-screen": "~0.16.2", diff --git a/app/mobile/src/context/UploadContext.js b/app/mobile/src/context/UploadContext.js new file mode 100644 index 00000000..9ac1b2a8 --- /dev/null +++ b/app/mobile/src/context/UploadContext.js @@ -0,0 +1,14 @@ +import { createContext } from 'react'; +import { useUploadContext } from './useUploadContext.hook'; + +export const UploadContext = createContext({}); + +export function UploadContextProvider({ children }) { + const { state, actions } = useUploadContext(); + return ( + + {children} + + ); +} + diff --git a/app/mobile/src/context/useCardContext.hook.js b/app/mobile/src/context/useCardContext.hook.js index 0e1b252e..1e1201b6 100644 --- a/app/mobile/src/context/useCardContext.hook.js +++ b/app/mobile/src/context/useCardContext.hook.js @@ -1,5 +1,6 @@ import { useState, useRef, useContext } from 'react'; import { StoreContext } from 'context/StoreContext'; +import { UploadContext } from 'context/UploadContext'; import { getCards } from 'api/getCards'; import { getCardProfile } from 'api/getCardProfile'; import { getCardDetail } from 'api/getCardDetail'; @@ -30,6 +31,7 @@ export function useCardContext() { cards: new Map(), }); const store = useContext(StoreContext); + const upload = useContext(UploadContext); const session = useRef(null); const curRevision = useRef(null); @@ -464,14 +466,26 @@ export function useCardContext() { const { detail, profile } = getCard(cardId); return getContactChannelTopicAssetUrl(profile.node, `${profile.guid}.${detail.token}`, channelId, topicId, assetId); }, - addChannelTopic: async (cardId, channelId, message, assets) => { + addChannelTopic: async (cardId, channelId, message, files) => { const { detail, profile } = getCard(cardId); - if (assets?.length > 0) { - console.log("UPLOAD"); + const node = profile.node; + const token = `${profile.guid}.${detail.token}`; + if (files?.length > 0) { + const topicId = await addContactChannelTopic(node, token, channelId, null, null); + upload.actions.addContactTopic(node, token, cardId, channelId, topicId, files, async (assets) => { + message.assets = assets; + await setContactChannelTopicSubject(node, token, channelId, topicId, message); + }, async () => { + try { + await removeContactChannelTopic(node, token, channelId, topicId); + } + catch (err) { + console.log(err); + } + }); } else { - await addContactChannelTopic(profile.node, `${profile.guid}.${detail.token}`, channelId, message, []); - // sync channel + await addContactChannelTopic(node, token, channelId, message, []); } }, setChannelTopicSubject: async (cardId, channelId, topicId, data) => { diff --git a/app/mobile/src/context/useChannelContext.hook.js b/app/mobile/src/context/useChannelContext.hook.js index 0eca0782..e1fc7723 100644 --- a/app/mobile/src/context/useChannelContext.hook.js +++ b/app/mobile/src/context/useChannelContext.hook.js @@ -1,5 +1,6 @@ import { useState, useRef, useContext } from 'react'; import { StoreContext } from 'context/StoreContext'; +import { UploadContext } from 'context/UploadContext'; import { getChannels } from 'api/getChannels'; import { getChannelDetail } from 'api/getChannelDetail'; import { getChannelSummary } from 'api/getChannelSummary'; @@ -20,6 +21,7 @@ export function useChannelContext() { channels: new Map(), }); const store = useContext(StoreContext); + const upload = useContext(UploadContext); const session = useRef(null); const curRevision = useRef(null); @@ -208,14 +210,24 @@ export function useChannelContext() { const { server, appToken } = session.current; return getChannelTopicAssetUrl(server, appToken, channelId, topicId, assetId); }, - addTopic: async (channelId, message, assets) => { + addTopic: async (channelId, message, files) => { const { server, appToken } = session.current; - if (assets?.length) { - console.log("UPLOAD"); + if (files?.length > 0) { + const topicId = await addChannelTopic(server, appToken, channelId, null, null); + upload.actions.addTopic(server, appToken, channelId, topicId, files, async (assets) => { + message.assets = assets; + await setChannelTopicSubject(server, appToken, channelId, topicId, message); + }, async () => { + try { + await removeChannelTopic(server, appToken, channelId, topicId); + } + catch (err) { + console.log(err); + } + }); } else { await addChannelTopic(server, appToken, channelId, message, []); - //sync channels } }, setTopicSubject: async (channelId, topicId, data) => { diff --git a/app/mobile/src/context/useConversationContext.hook.js b/app/mobile/src/context/useConversationContext.hook.js index 3ddfb8ed..0c9fb41c 100644 --- a/app/mobile/src/context/useConversationContext.hook.js +++ b/app/mobile/src/context/useConversationContext.hook.js @@ -138,15 +138,23 @@ export function useConversationContext() { topics.current.delete(topic.id); await clearTopicItem(cardId, channelId, topic.id); } - const cached = topics.current.get(topic.id); - if (!cached || cached.detailRevision != topic.data.detailRevision) { - if (!topic.data.topicDetail) { - const updated = await getTopic(cardId, channelId, topic.id); - topic.data.topicDetail = updated.data.topicDetail; + else { + const cached = topics.current.get(topic.id); + if (!cached || cached.detailRevision != topic.data.detailRevision) { + if (!topic.data.topicDetail) { + const updated = await getTopic(cardId, channelId, topic.id); + topic.data = updated.data; + } + if (!topic.data) { + topics.current.delete(topic.id); + await clearTopicItem(cardId, channelId, topic.id); + } + else { + await setTopicItem(cardId, channelId, topic); + const { id, revision, data } = topic; + topics.current.set(id, { topicId: id, revision: revision, detailRevision: topic.data.detailRevision, detail: topic.data.topicDetail }); + } } - await setTopicItem(cardId, channelId, topic); - const { id, revision, data } = topic; - topics.current.set(id, { topicId: id, revision: revision, detailRevision: topic.data.detailRevision, detail: topic.data.topicDetail }); } } await setSyncRevision(cardId, channelId, res.revision); diff --git a/app/mobile/src/context/useUploadContext.hook.js b/app/mobile/src/context/useUploadContext.hook.js new file mode 100644 index 00000000..ab14e2b1 --- /dev/null +++ b/app/mobile/src/context/useUploadContext.hook.js @@ -0,0 +1,216 @@ +import { useState, useRef } from 'react'; +import axios from 'axios'; + +export function useUploadContext() { + + const [state, setState] = useState({ + progress: new Map(), + }); + const channels = useRef(new Map()); + const index = useRef(0); + + const updateState = (value) => { + setState((s) => ({ ...s, ...value })); + }; + + const updateComplete = (channel, topic) => { + let topics = channels.current.get(channel); + if (topics) { + topics.delete(topic); + } + updateProgress(); + } + + const updateProgress = () => { + let progress = new Map(); + channels.current.forEach((topics, channel) => { + let assets = []; + topics.forEach((entry, topic, map) => { + let active = entry.active ? 1 : 0; + assets.push({ + upload: entry.index, + topicId: topic, + active: entry.active, + uploaded: entry.assets.length, + index: entry.assets.length + active, + count: entry.assets.length + entry.files.length + active, + error: entry.error, + }); + }); + if (assets.length) { + progress.set(channel, assets.sort((a, b) => (a.upload < b.upload) ? 1 : -1)); + } + }); + updateState({ progress }); + } + + const abort = (channelId, topicId) => { + const channel = channels.current.get(channelId); + if (channel) { + const topic = channel.get(topicId); + if (topic) { + topic.cancel.abort(); + channel.delete(topicId); + updateProgress(); + } + } + } + + const actions = { + addTopic: (node, token, channelId, topicId, files, success, failure) => { + const controller = new AbortController(); + const entry = { + index: index.current, + url: `https://${node}/content/channels/${channelId}/topics/${topicId}/assets?agent=${token}`, + files, + assets: [], + current: null, + error: false, + success, + failure, + cancel: controller, + } + index.current += 1; + const key = `:${channelId}`; + if (!channels.current.has(key)) { + channels.current.set(key, new Map()); + } + const topics = channels.current.get(key); + topics.set(topicId, entry); + upload(entry, updateProgress, () => { updateComplete(key, topicId) } ); + }, + cancelTopic: (channelId, topicId) => { + abort(`:${channelId}`, topicId); + }, + addContactTopic: (node, token, cardId, channelId, topicId, files, success, failure) => { + const controller = new AbortController(); + const entry = { + index: index.current, + url: `https://${node}/content/channels/${channelId}/topics/${topicId}/assets?contact=${token}`, + files, + assets: [], + current: null, + error: false, + success, + failure, + cancel: controller, + } + index.current += 1; + const key = `${cardId}:${channelId}`; + if (!channels.current.has(key)) { + channels.current.set(key, new Map()); + } + const topics = channels.current.get(key); + topics.set(topicId, entry); + upload(entry, updateProgress, () => { updateComplete(key, topicId) }); + }, + cancelContactTopic: (cardId, channelId, topicId) => { + abort(`${cardId}:${channelId}`, topicId); + }, + clearErrors: (cardId, channelId) => { + const key = cardId ? `${cardId}:${channelId}` : `:${channelId}`; + const topics = channels.current.get(key); + if (topics) { + topics.forEach((topic, topicId) => { + if (topic.error) { + topic.cancel.abort(); + topics.delete(topicId); + updateProgress(); + } + }); + } + }, + clear: () => { + channels.current.forEach((topics, channelId) => { + topics.forEach((assets, topicId) => { + assets.cancel.abort(); + }); + }); + channels.current.clear(); + updateProgress(); + } + } + + return { state, actions } +} + +async function upload(entry, update, complete) { + if (!entry.files?.length) { + entry.success(entry.assets); + complete(); + } + else { + const file = entry.files.shift(); + entry.active = {}; + try { + if (file.type === 'image') { + const formData = new FormData(); + 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, { + signal: entry.cancel.signal, + onUploadProgress: (ev) => { + const { loaded, total } = ev; + entry.active = { loaded, total } + update(); + }, + }); + entry.assets.push({ + image: { + thumb: asset.data.find(item => item.transform === 'ithumb;photo').assetId, + full: asset.data.find(item => item.transform === 'ilg;photo').assetId, + } + }); + } + else if (file.type === 'video') { + const formData = new FormData(); + formData.append("asset", {uri: 'file://' + file.data, name: 'asset', type: 'application/octent-stream'}); + 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, { + signal: entry.cancel.signal, + onUploadProgress: (ev) => { + const { loaded, total } = ev; + entry.active = { loaded, total } + update(); + }, + }); + entry.assets.push({ + video: { + thumb: asset.data.find(item => item.transform === thumb).assetId, + lq: asset.data.find(item => item.transform === 'vlq;video').assetId, + hd: asset.data.find(item => item.transform === 'vhd;video').assetId, + } + }); + } + else if (file.type === 'audio') { + const formData = new FormData(); + 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, { + signal: entry.cancel.signal, + onUploadProgress: (ev) => { + const { loaded, total } = ev; + entry.active = { loaded, total } + update(); + }, + }); + entry.assets.push({ + audio: { + label: file.label, + full: asset.data.find(item => item.transform === 'acopy;audio').assetId, + } + }); + } + entry.active = null; + upload(entry, update, complete); + } + catch (err) { + console.log(err); + entry.failure(); + entry.error = true; + update(); + } + } +} + diff --git a/app/mobile/yarn.lock b/app/mobile/yarn.lock index 0165ce9d..b2270674 100644 --- a/app/mobile/yarn.lock +++ b/app/mobile/yarn.lock @@ -2111,6 +2111,15 @@ atob@^2.1.2: resolved "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz" integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== +axios@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.1.0.tgz#94d25e6524743c7fc33954dd536687bbb957793a" + integrity sha512-hsJgcqz4JY7f+HZ4cWTrPZ6tZNCNFPTRx1MjRqu/hbpgpHdSCUpLVuplc+jE/h7dOvyANtw/ERA3HC2Rz/QoMg== + dependencies: + follow-redirects "^1.15.0" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + babel-core@^7.0.0-bridge.0: version "7.0.0-bridge.0" resolved "https://registry.npmjs.org/babel-core/-/babel-core-7.0.0-bridge.0.tgz" @@ -3441,6 +3450,11 @@ flow-parser@^0.121.0: resolved "https://registry.npmjs.org/flow-parser/-/flow-parser-0.121.0.tgz" integrity sha512-1gIBiWJNR0tKUNv8gZuk7l9rVX06OuLzY9AoGio7y/JT4V1IZErEMEq2TJS+PFcw/y0RshZ1J/27VfK1UQzYVg== +follow-redirects@^1.15.0: + version "1.15.2" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13" + integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== + fontfaceobserver@^2.1.0: version "2.3.0" resolved "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz" @@ -3460,6 +3474,15 @@ form-data@^3.0.1: combined-stream "^1.0.8" mime-types "^2.1.12" +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + fragment-cache@^0.2.1: version "0.2.1" resolved "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz" @@ -5516,6 +5539,11 @@ prop-types@*, prop-types@^15.5.10, prop-types@^15.7.2: object-assign "^4.1.1" react-is "^16.13.1" +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + pump@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz"