diff --git a/doc/api.oa3 b/doc/api.oa3 index 77af5391..392d00d9 100644 --- a/doc/api.oa3 +++ b/doc/api.oa3 @@ -2726,6 +2726,12 @@ paths: required: true schema: type: string + - name: confirm + in: query + description: confirmed state of topic + required: false + schema: + type: boolean responses: '200': description: success diff --git a/net/server/go.mod b/net/server/go.mod index 2422496a..949daebd 100644 --- a/net/server/go.mod +++ b/net/server/go.mod @@ -20,6 +20,8 @@ require ( github.com/theckman/go-securerandom v0.1.1 // indirect github.com/valyala/fastjson v1.6.3 // indirect golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 // indirect + golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect + golang.org/x/tools v0.1.11 // indirect gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect gorm.io/driver/sqlite v1.2.6 // indirect gorm.io/gorm v1.22.5 // indirect diff --git a/net/server/go.sum b/net/server/go.sum index c4207304..31ec5c13 100644 --- a/net/server/go.sum +++ b/net/server/go.sum @@ -39,8 +39,23 @@ github.com/theckman/go-securerandom v0.1.1 h1:5KctSyM0D5KKFK+bsypIyLq7yik0CEaI5i github.com/theckman/go-securerandom v0.1.1/go.mod h1:bmkysLfBH6i891sBpcP4xRM3XIB7jMeiKJB31jlResI= github.com/valyala/fastjson v1.6.3 h1:tAKFnnwmeMGPbwJ7IwxcTPCNr3uIzoIj3/Fh90ra4xc= github.com/valyala/fastjson v1.6.3/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+WrnIIS6dNnNRe0WB02W0F4M= golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.1.11 h1:loJ25fNOEhSXfHrpoGj91eCUThwdNX6u24rO1xnNteY= +golang.org/x/tools v0.1.11/go.mod h1:SgwaegtQh8clINPpECJMqnxLv9I09HLqnW3RMqW0CA4= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= diff --git a/net/server/internal/api_setChannelTopicSubject.go b/net/server/internal/api_setChannelTopicSubject.go index 19ce9905..baf5eca4 100644 --- a/net/server/internal/api_setChannelTopicSubject.go +++ b/net/server/internal/api_setChannelTopicSubject.go @@ -13,6 +13,7 @@ func SetChannelTopicSubject(w http.ResponseWriter, r *http.Request) { // scan parameters params := mux.Vars(r) topicId := params["topicId"] + confirm := r.FormValue("confirm"); var subject Subject if err := ParseRequest(r, w, &subject); err != nil { @@ -52,6 +53,16 @@ func SetChannelTopicSubject(w http.ResponseWriter, r *http.Request) { if res := tx.Model(topicSlot.Topic).Update("data_type", subject.DataType).Error; res != nil { return res } + if confirm == "true" { + if res := tx.Model(topicSlot.Topic).Update("status", APP_TOPICCONFIRMED).Error; res != nil { + return res + } + } + if confirm == "false" { + if res := tx.Model(topicSlot.Topic).Update("status", APP_TOPICUNCONFIRMED).Error; res != nil { + return res + } + } if res := tx.Model(&topicSlot.Topic).Update("detail_revision", act.ChannelRevision + 1).Error; res != nil { return res } diff --git a/net/web/package.json b/net/web/package.json index 08dd6396..030ea369 100644 --- a/net/web/package.json +++ b/net/web/package.json @@ -7,6 +7,7 @@ "@testing-library/react": "^12.0.0", "@testing-library/user-event": "^13.2.1", "antd": "^4.19.1", + "axios": "^0.27.2", "base-64": "^1.0.0", "react": "^17.0.2", "react-color": "^2.19.3", diff --git a/net/web/src/App.js b/net/web/src/App.js index 95ee4030..213a7b5a 100644 --- a/net/web/src/App.js +++ b/net/web/src/App.js @@ -8,6 +8,7 @@ import { CardContextProvider } from 'context/CardContext'; import { ChannelContextProvider } from 'context/ChannelContext'; import { ConversationContextProvider } from 'context/ConversationContext'; import { StoreContextProvider } from 'context/StoreContext'; +import { UploadContextProvider } from 'context/UploadContext'; import { Home } from './Home/Home'; import { Admin } from './Admin/Admin'; import { Login } from './Login/Login'; @@ -22,49 +23,51 @@ import 'antd/dist/antd.min.css'; function App() { return ( - - - - - - - - -
- -
-
- - - } /> - } /> - } /> - } /> - }> - } /> - } /> - - - - } /> - - - - } /> - - - -
-
-
-
-
-
-
-
-
+ + + + + + + + + +
+ +
+
+ + + } /> + } /> + } /> + } /> + }> + } /> + } /> + + + + } /> + + + + } /> + + + +
+
+
+
+
+
+
+
+
+
); } diff --git a/net/web/src/User/Conversation/AddTopic/useAddTopic.hook.js b/net/web/src/User/Conversation/AddTopic/useAddTopic.hook.js index 5eee735c..268c0224 100644 --- a/net/web/src/User/Conversation/AddTopic/useAddTopic.hook.js +++ b/net/web/src/User/Conversation/AddTopic/useAddTopic.hook.js @@ -13,6 +13,7 @@ export function useAddTopic() { textSize: 14, textSizeSet: false, busy: false, + progress: null, }); const { cardId, channelId } = useParams(); diff --git a/net/web/src/User/Conversation/Conversation.jsx b/net/web/src/User/Conversation/Conversation.jsx index ac794308..a4d4f28a 100644 --- a/net/web/src/User/Conversation/Conversation.jsx +++ b/net/web/src/User/Conversation/Conversation.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef } from 'react' import { ExclamationCircleOutlined, CloseOutlined, UserOutlined } from '@ant-design/icons'; import { useConversation } from './useConversation.hook'; -import { Button, Input, Checkbox, Modal, Spin, Tooltip } from 'antd' +import { Button, Input, Progress, Checkbox, Modal, Spin, Tooltip } from 'antd' import { ConversationWrapper, ConversationButton, EditButton, CloseButton, ListItem, BusySpin, Offsync } from './Conversation.styled'; import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from 'react-virtualized'; import { AddTopic } from './AddTopic/AddTopic'; @@ -45,6 +45,20 @@ export function Conversation() { setShowEdit(true); } + const uploadProgress = () => { + let progress = []; + for (let entry of state.progress) { + progress.push( +
+
{ entry.index }/{ entry.count }
+ + +
+ ); + } + return progress; + } + const onMembers = () => { setShowMembers(true); } @@ -100,6 +114,11 @@ export function Conversation() {
+ { state.progress && ( +
+ { uploadProgress() } +
+ )} diff --git a/net/web/src/User/Conversation/Conversation.styled.jsx b/net/web/src/User/Conversation/Conversation.styled.jsx index 8cf071ba..418bb7ba 100644 --- a/net/web/src/User/Conversation/Conversation.styled.jsx +++ b/net/web/src/User/Conversation/Conversation.styled.jsx @@ -72,7 +72,36 @@ export const ConversationWrapper = styled.div` flex-grow: 1; flex-direction: column; width: 100%; - overflow: auto; + overflow: auto; + + .uploading { + position: absolute; + top: 0px; + right: 0px; + display: flex; + flex-direction: column; + z-index: 10; + border-bottom: 1px solid #888888; + border-left: 1px solid #888888; + border-bottom-left-radius: 4px; + padding-left: 8px; + background-color: #ffffff; + + .progress { + width: 250px; + display: flex; + flex-direction: row; + align-items: center; + + .index { + display: flex; + width: 64px; + justify-content: center; + color: #444444; + font-size: 12px; + } + } + } } `; diff --git a/net/web/src/User/Conversation/useConversation.hook.js b/net/web/src/User/Conversation/useConversation.hook.js index 829f7442..74dd0d67 100644 --- a/net/web/src/User/Conversation/useConversation.hook.js +++ b/net/web/src/User/Conversation/useConversation.hook.js @@ -2,6 +2,7 @@ import { useContext, useState, useEffect, useRef } from 'react'; import { useNavigate, useLocation, useParams } from "react-router-dom"; import { ConversationContext } from 'context/ConversationContext'; import { StoreContext } from 'context/StoreContext'; +import { UploadContext } from 'context/UploadContext'; export function useConversation() { @@ -18,11 +19,21 @@ export function useConversation() { const navigate = useNavigate(); const conversation = useContext(ConversationContext); const store = useContext(StoreContext); + const upload = useContext(UploadContext); const updateState = (value) => { setState((s) => ({ ...s, ...value })); } + useEffect(() => { + if (cardId) { + updateState({ progress: upload.state.progress.get(`${cardId}:${channelId}`) }); + } + else { + updateState({ progress: upload.state.progress.get(`:${channelId}`) }); + } + }, [upload]); + const actions = { close: () => { navigate('/user') diff --git a/net/web/src/api/addChannelTopic.js b/net/web/src/api/addChannelTopic.js index c7829a52..8e806b39 100644 --- a/net/web/src/api/addChannelTopic.js +++ b/net/web/src/api/addChannelTopic.js @@ -1,8 +1,15 @@ import { checkResponse, fetchWithTimeout } from './fetchUtil'; -export async function addChannelTopic(token, channelId, message, assets ) { - - if (assets == null || assets.length == 0) { +export async function addChannelTopic(token, channelId, message, assets ): string { + + if (message == null && (assets == null || assets.length == 0)) { + let topic = await fetchWithTimeout(`/content/channels/${channelId}/topics?agent=${token}`, + { method: 'POST', body: JSON.stringify({}) }); + checkResponse(topic); + let slot = await topic.json(); + return slot.id; + } + else if (assets == null || assets.length == 0) { let subject = { data: JSON.stringify(message, (key, value) => { if (value !== null) return value }), datatype: 'superbasictopic' }; @@ -10,6 +17,8 @@ export async function addChannelTopic(token, channelId, message, assets ) { let topic = await fetchWithTimeout(`/content/channels/${channelId}/topics?agent=${token}&confirm=true`, { method: 'POST', body: JSON.stringify(subject) }); checkResponse(topic); + let slot = await topic.json(); + return slot.id; } else { @@ -78,6 +87,7 @@ export async function addChannelTopic(token, channelId, message, assets ) { let confirmed = await fetchWithTimeout(`/content/channels/${channelId}/topics/${slot.id}/confirmed?agent=${token}`, { method: 'PUT', body: JSON.stringify('confirmed') }); checkResponse(confirmed); + return slot.id; } } diff --git a/net/web/src/api/addContactChannelTopic.js b/net/web/src/api/addContactChannelTopic.js index ddc936d8..4db7ee66 100644 --- a/net/web/src/api/addContactChannelTopic.js +++ b/net/web/src/api/addContactChannelTopic.js @@ -2,8 +2,13 @@ import { checkResponse, fetchWithTimeout } from './fetchUtil'; export async function addContactChannelTopic(server, token, channelId, message, assets ) { - if (assets == null || assets.length == 0) { - + if (message == null || (assets == null || assets.length == 0)) { + let topic = await fetchWithTimeout(`https://${server}/content/channels/${channelId}/topics?contact=${token}`, + { method: 'POST', body: JSON.stringify({}) }); + checkResponse(topic); + return await topic.json().id; + } + else if (assets == null || assets.length == 0) { let subject = { data: JSON.stringify(message, (key, value) => { if (value !== null) return value }), datatype: 'superbasictopic' }; @@ -11,9 +16,9 @@ export async function addContactChannelTopic(server, token, channelId, message, let topic = await fetchWithTimeout(`https://${server}/content/channels/${channelId}/topics?contact=${token}&confirm=true`, { method: 'POST', body: JSON.stringify(subject) }); checkResponse(topic); + return await topic.json().id; } else { - let topic = await fetchWithTimeout(`https://${server}/content/channels/${channelId}/topics?contact=${token}`, { method: 'POST', body: JSON.stringify({}) }); checkResponse(topic); @@ -79,6 +84,7 @@ export async function addContactChannelTopic(server, token, channelId, message, let confirmed = await fetchWithTimeout(`https://${server}/content/channels/${channelId}/topics/${slot.id}/confirmed?contact=${token}`, { method: 'PUT', body: JSON.stringify('confirmed') }); checkResponse(confirmed); + return slot.id; } } diff --git a/net/web/src/api/setChannelTopicAsset.js b/net/web/src/api/setChannelTopicAsset.js new file mode 100644 index 00000000..116b4801 --- /dev/null +++ b/net/web/src/api/setChannelTopicAsset.js @@ -0,0 +1,48 @@ +import { checkResponse, fetchWithTimeout } from './fetchUtil'; + +export async function setChannelTopicSubject(token, channelId, topicId, asset) { + if (asset.image) { + const formData = new FormData(); + formData.append('asset', asset.image); + let transform = encodeURIComponent(JSON.stringify(["ithumb;photo", "icopy;photo"])); + let topicAsset = await fetch(`/content/channels/${channelId}/topics/${slot.id}/assets?transforms=${transform}&agent=${token}`, { method: 'POST', body: formData }); + checkResponse(topicAsset); + let assetEntry = await topicAsset.json(); + return { + image: { + thumb: assetEntry.find(item => item.transform === 'ithumb;photo').assetId, + full: assetEntry.find(item => item.transform === 'icopy;photo').assetId, + } + }; + } + else if (asset.video) { + const formData = new FormData(); + formData.append('asset', asset.video); + let thumb = 'vthumb;video;' + asset.position; + let transform = encodeURIComponent(JSON.stringify(["vlq;video", "vhd;video", thumb])); + let topicAsset = await fetch(`/content/channels/${channelId}/topics/${slot.id}/assets?transforms=${transform}&agent=${token}`, { method: 'POST', body: formData }); + checkResponse(topicAsset); + let assetEntry = await topicAsset.json(); + return { + video: { + thumb: assetEntry.find(item => item.transform === thumb).assetId, + lq: assetEntry.find(item => item.transform === 'vlq;video').assetId, + hd: assetEntry.find(item => item.transform === 'vhd;video').assetId, + } + }; + } + else if (asset.audio) { + const formData = new FormData(); + formData.append('asset', asset.audio); + let transform = encodeURIComponent(JSON.stringify(["acopy;audio"])); + let topicAsset = await fetch(`/content/channels/${channelId}/topics/${slot.id}/assets?transforms=${transform}&agent=${token}`, { method: 'POST', body: formData }); + checkResponse(topicAsset); + let assetEntry = await topicAsset.json(); + return { + audio: { + label: asset.label, + full: assetEntry.find(item => item.transform === 'acopy;audio').assetId, + } + }; + } +} diff --git a/net/web/src/api/setChannelTopicSubject.js b/net/web/src/api/setChannelTopicSubject.js index 6e227efa..3ef43810 100644 --- a/net/web/src/api/setChannelTopicSubject.js +++ b/net/web/src/api/setChannelTopicSubject.js @@ -5,7 +5,7 @@ export async function setChannelTopicSubject(token, channelId, topicId, data) { if (value !== null) return value }), datatype: 'superbasictopic' }; - let channel = await fetchWithTimeout(`/content/channels/${channelId}/topics/${topicId}/subject?agent=${token}`, + let channel = await fetchWithTimeout(`/content/channels/${channelId}/topics/${topicId}/subject?agent=${token}&confirm=true`, { method: 'PUT', body: JSON.stringify(subject) }); checkResponse(channel); } diff --git a/net/web/src/api/setContactChannelTopicAsset.js b/net/web/src/api/setContactChannelTopicAsset.js new file mode 100644 index 00000000..333c3b6c --- /dev/null +++ b/net/web/src/api/setContactChannelTopicAsset.js @@ -0,0 +1,48 @@ +import { checkResponse, fetchWithTimeout } from './fetchUtil'; + +export async function setContactChannelTopicSubject(server, token, channelId, topicId, asset) { + if (asset.image) { + const formData = new FormData(); + formData.append('asset', asset.image); + let transform = encodeURIComponent(JSON.stringify(["ithumb;photo", "icopy;photo"])); + let topicAsset = await fetch(`https://${server}/content/channels/${channelId}/topics/${slot.id}/assets?transforms=${transform}&contact=${token}`, { method: 'POST', body: formData }); + checkResponse(topicAsset); + let assetEntry = await topicAsset.json(); + return { + image: { + thumb: assetEntry.find(item => item.transform === 'ithumb;photo').assetId, + full: assetEntry.find(item => item.transform === 'icopy;photo').assetId, + } + }; + } + else if (asset.video) { + const formData = new FormData(); + formData.append('asset', asset.video); + let thumb = "vthumb;video;" + asset.position + let transform = encodeURIComponent(JSON.stringify(["vhd;video", "vlq;video", thumb])); + let topicAsset = await fetch(`https://${server}/content/channels/${channelId}/topics/${slot.id}/assets?transforms=${transform}&contact=${token}`, { method: 'POST', body: formData }); + checkResponse(topicAsset); + let assetEntry = await topicAsset.json(); + return { + video: { + thumb: assetEntry.find(item => item.transform === thumb).assetId, + lq: assetEntry.find(item => item.transform === 'vlq;video').assetId, + hd: assetEntry.find(item => item.transform === 'vhd;video').assetId, + } + }; + } + else if (asset.audio) { + const formData = new FormData(); + formData.append('asset', asset.audio); + let transform = encodeURIComponent(JSON.stringify(["acopy;audio"])); + let topicAsset = await fetch(`https://${server}/content/channels/${channelId}/topics/${slot.id}/assets?transforms=${transform}&contact=${token}`, { method: 'POST', body: formData }); + checkResponse(topicAsset); + let assetEntry = await topicAsset.json(); + return { + audio: { + label: asset.label, + full: assetEntry.find(item => item.transform === 'acopy;audio').assetId, + } + }; + } +} diff --git a/net/web/src/api/setContactChannelTopicSubject.js b/net/web/src/api/setContactChannelTopicSubject.js index 2b2fb337..26cd412f 100644 --- a/net/web/src/api/setContactChannelTopicSubject.js +++ b/net/web/src/api/setContactChannelTopicSubject.js @@ -5,7 +5,7 @@ export async function setContactChannelTopicSubject(server, token, channelId, to if (value !== null) return value }), datatype: 'superbasictopic' }; - let channel = await fetchWithTimeout(`https://${server}//content/channels/${channelId}/topics/${topicId}/subject?contact=${token}`, + let channel = await fetchWithTimeout(`https://${server}//content/channels/${channelId}/topics/${topicId}/subject?contact=${token}&confirm=true`, { method: 'PUT', body: JSON.stringify(subject) }); checkResponse(channel); } diff --git a/net/web/src/context/UploadContext.js b/net/web/src/context/UploadContext.js new file mode 100644 index 00000000..9ac1b2a8 --- /dev/null +++ b/net/web/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/net/web/src/context/useCardContext.hook.js b/net/web/src/context/useCardContext.hook.js index 38e76c17..7e6f27f4 100644 --- a/net/web/src/context/useCardContext.hook.js +++ b/net/web/src/context/useCardContext.hook.js @@ -1,4 +1,4 @@ -import { useEffect, useState, useRef } from 'react'; +import { useEffect, useState, useRef, useContext } from 'react'; import { getContactChannels } from 'api/getContactChannels'; import { getContactChannelDetail } from 'api/getContactChannelDetail'; import { getContactChannelSummary } from 'api/getContactChannelSummary'; @@ -22,12 +22,14 @@ import { getContactChannelTopic } from 'api/getContactChannelTopic'; import { getContactChannelTopicAssetUrl } from 'api/getContactChannelTopicAssetUrl'; import { addCard } from 'api/addCard'; import { removeCard } from 'api/removeCard'; +import { UploadContext } from 'context/UploadContext'; export function useCardContext() { const [state, setState] = useState({ init: false, cards: new Map(), }); + const upload = useContext(UploadContext); const access = useRef(null); const revision = useRef(null); const next = useRef(null); @@ -281,11 +283,29 @@ export function useCardContext() { let node = cardProfile.node; await setContactChannelTopicSubject(node, token, channelId, topicId, data); }, - addChannelTopic: async (cardId, channelId, message, assets) => { + addChannelTopic: async (cardId, channelId, message, files) => { let { cardProfile, cardDetail } = cards.current.get(cardId).data; let token = cardProfile.guid + '.' + cardDetail.token; let node = cardProfile.node; - await addContactChannelTopic(node, token, channelId, message, assets); + if (files?.length) { + const topicId = await addContactChannelTopic(node, token, channelId, null, null); + upload.actions.addContactTopic(node, token, cardId, channelId, topicId, files, async (assets) => { + console.log("success, finalize topic"); + message.assets = assets; + await setContactChannelTopicSubject(node, token, channelId, topicId, message); + }, async () => { + console.log("failed, delete topic"); + try { + await removeContactChannelTopic(node, token, channelId, topicId); + } + catch(err) { + console.log(err); + } + }); + } + else { + await addContactChannelTopic(node, token, channelId, message, files); + } }, getChannel: (cardId, channelId) => { let card = cards.current.get(cardId); diff --git a/net/web/src/context/useChannelContext.hook.js b/net/web/src/context/useChannelContext.hook.js index 3cb0f490..7efcea2a 100644 --- a/net/web/src/context/useChannelContext.hook.js +++ b/net/web/src/context/useChannelContext.hook.js @@ -1,4 +1,4 @@ -import { useEffect, useState, useRef } from 'react'; +import { useEffect, useState, useRef, useContext } from 'react'; import { getChannels } from 'api/getChannels'; import { getChannelDetail } from 'api/getChannelDetail'; import { getChannelSummary } from 'api/getChannelSummary'; @@ -13,11 +13,14 @@ import { getChannelTopicAssetUrl } from 'api/getChannelTopicAssetUrl'; import { setChannelSubject } from 'api/setChannelSubject'; import { setChannelCard } from 'api/setChannelCard'; import { clearChannelCard } from 'api/clearChannelCard'; +import { UploadContext } from 'context/UploadContext'; + export function useChannelContext() { const [state, setState] = useState({ init: false, channels: new Map(), }); + const upload = useContext(UploadContext); const access = useRef(null); const revision = useRef(null); const channels = useRef(new Map()); @@ -117,8 +120,26 @@ export function useChannelContext() { setChannelTopicSubject: async (channelId, topicId, data) => { return await setChannelTopicSubject(access.current, channelId, topicId, data); }, - addChannelTopic: async (channelId, message, assets) => { - await addChannelTopic(access.current, channelId, message, assets); + addChannelTopic: async (channelId, message, files) => { + if (files?.length) { + const topicId = await addChannelTopic(access.current, channelId, null, null); + upload.actions.addTopic(access.current, channelId, topicId, files, async (assets) => { + console.log("success, finalize topic"); + message.assets = assets; + await setChannelTopicSubject(access.current, channelId, topicId, message); + }, async () => { + console.log("failed, delete topic"); + try { + await removeChannelTopic(access.current, channelId, topicId); + } + catch(err) { + console.log(err); + } + }); + } + else { + await addChannelTopic(access.current, channelId, message, files); + } }, getChannel: (channelId) => { return channels.current.get(channelId); diff --git a/net/web/src/context/useUploadContext.hook.js b/net/web/src/context/useUploadContext.hook.js new file mode 100644 index 00000000..9e184d42 --- /dev/null +++ b/net/web/src/context/useUploadContext.hook.js @@ -0,0 +1,173 @@ +import { useEffect, useState, useRef, useContext } 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, + 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 actions = { + addTopic: (token, channelId, topicId, files, success, failure) => { + const entry = { + index: index.current, + url: `/content/channels/${channelId}/topics/${topicId}/assets?agent=${token}`, + files, + assets: [], + current: null, + error: false, + success, + failure + } + 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) => { + }, + addContactTopic: (server, token, cardId, channelId, topicId, files, success, failure) => { + const entry = { + index: index.current, + url: `https://${server}/content/channels/${channelId}/topics/${topicId}/assets?contact=${token}`, + files, + assets: [], + current: null, + error: false, + success, + failure + } + 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) => { + }, + reset: () => { + } + } + + 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.image) { + const formData = new FormData(); + formData.append('asset', file.image); + let transform = encodeURIComponent(JSON.stringify(["ithumb;photo", "icopy;photo"])); + let asset = await axios.post(`${entry.url}&transforms=${transform}`, formData, { + 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 === 'icopy;photo').assetId, + } + }); + } + else if (file.video) { + const formData = new FormData(); + 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, { + 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.audio) { + 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, { + onUploadProgress: (ev) => { + const { loaded, total } = ev; + entry.active = { loaded, total } + update(); + }, + }); + entry.assets.push({ + audio: { + label: asset.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; + } + } +} + diff --git a/net/web/yarn.lock b/net/web/yarn.lock index 5927bfc9..71b10270 100644 --- a/net/web/yarn.lock +++ b/net/web/yarn.lock @@ -2589,6 +2589,14 @@ axe-core@^4.3.5: resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.4.1.tgz" integrity sha512-gd1kmb21kwNuWr6BQz8fv6GNECPBnUasepcoLbekws23NVBLODdsClRZ+bQ8+9Uomf3Sm3+Vwn0oYG9NvwnJCw== +axios@^0.27.2: + version "0.27.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.27.2.tgz#207658cc8621606e586c85db4b41a750e756d972" + integrity sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ== + dependencies: + follow-redirects "^1.14.9" + form-data "^4.0.0" + axobject-query@^2.2.0: version "2.2.0" resolved "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz" @@ -4456,6 +4464,11 @@ follow-redirects@^1.0.0: resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz" integrity sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w== +follow-redirects@^1.14.9: + version "1.15.1" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.1.tgz#0ca6a452306c9b276e4d3127483e29575e207ad5" + integrity sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA== + fork-ts-checker-webpack-plugin@^6.5.0: version "6.5.0" resolved "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.0.tgz" @@ -4484,6 +4497,15 @@ form-data@^3.0.0: 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" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz"