diff --git a/doc/api.oa3 b/doc/api.oa3 index b999ad5a..b53122a1 100644 --- a/doc/api.oa3 +++ b/doc/api.oa3 @@ -2179,7 +2179,7 @@ paths: delete: tags: - content - description: Remove specified channel. Access granted to app token of account holder. + description: Remove specified channel or membership. When invoked by account holder, channel is removed. When invoked by member, membership is removed. operationId: remove-channel security: - bearerAuth: [] @@ -3862,4 +3862,3 @@ components: scheme: bearer - diff --git a/net/server/internal/api_removeChannel.go b/net/server/internal/api_removeChannel.go index fdee4f8b..a136ef79 100644 --- a/net/server/internal/api_removeChannel.go +++ b/net/server/internal/api_removeChannel.go @@ -9,15 +9,33 @@ import ( ) func RemoveChannel(w http.ResponseWriter, r *http.Request) { + var err error + var code int // scan parameters params := mux.Vars(r) channelId := params["channelId"] // validate contact access - account, code, err := BearerAppToken(r, false) - if err != nil { - ErrResponse(w, code, err) + var account *store.Account + var contact *store.Card + tokenType := ParamTokenType(r); + if tokenType == APP_TOKENAGENT { + account, code, err = ParamAgentToken(r, false); + if err != nil { + ErrResponse(w, code, err); + return + } + } else if tokenType == APP_TOKENCONTACT { + contact, code, err = ParamContactToken(r, true) + if err != nil { + ErrResponse(w, code, err); + return + } + account = &contact.Account + } else { + err = errors.New("unknown token type") + code = http.StatusBadRequest return } @@ -47,50 +65,79 @@ func RemoveChannel(w http.ResponseWriter, r *http.Request) { } } - err = store.DB.Transaction(func(tx *gorm.DB) error { - if res := tx.Model(&slot.Channel).Association("Groups").Clear(); res != nil { - return res + if contact == nil { + err = store.DB.Transaction(func(tx *gorm.DB) error { + if res := tx.Model(&slot.Channel).Association("Groups").Clear(); res != nil { + return res + } + slot.Channel.Groups = []store.Group{} + if res := tx.Model(&slot.Channel).Association("Cards").Clear(); res != nil { + return res + } + slot.Channel.Cards = []store.Card{} + if res := tx.Where("channel_id = ?", slot.Channel.ID).Delete(&store.Tag{}).Error; res != nil { + return res + } + if res := tx.Where("channel_id = ?", slot.Channel.ID).Delete(&store.TagSlot{}).Error; res != nil { + return res + } + if res := tx.Where("channel_id = ?", slot.Channel.ID).Delete(&store.Asset{}).Error; res != nil { + return res + } + if res := tx.Where("channel_id = ?", slot.Channel.ID).Delete(&store.Topic{}).Error; res != nil { + return res + } + if res := tx.Where("channel_id = ?", slot.Channel.ID).Delete(&store.TopicSlot{}).Error; res != nil { + return res + } + slot.Channel.Topics = []store.Topic{} + if res := tx.Delete(&slot.Channel).Error; res != nil { + return res + } + slot.Channel = nil + if res := tx.Model(&slot).Update("channel_id", 0).Error; res != nil { + return res + } + if res := tx.Model(&slot).Update("revision", account.ChannelRevision + 1).Error; res != nil { + return res + } + if res := tx.Model(account).Update("channel_revision", account.ChannelRevision + 1).Error; res != nil { + return res + } + return nil + }) + if err != nil { + ErrResponse(w, http.StatusInternalServerError, err) + return } - slot.Channel.Groups = []store.Group{} - if res := tx.Model(&slot.Channel).Association("Cards").Clear(); res != nil { - return res - } - slot.Channel.Cards = []store.Card{} - if res := tx.Where("channel_id = ?", slot.Channel.ID).Delete(&store.Tag{}).Error; res != nil { - return res - } - if res := tx.Where("channel_id = ?", slot.Channel.ID).Delete(&store.TagSlot{}).Error; res != nil { - return res - } - if res := tx.Where("channel_id = ?", slot.Channel.ID).Delete(&store.Asset{}).Error; res != nil { - return res - } - if res := tx.Where("channel_id = ?", slot.Channel.ID).Delete(&store.Topic{}).Error; res != nil { - return res - } - if res := tx.Where("channel_id = ?", slot.Channel.ID).Delete(&store.TopicSlot{}).Error; res != nil { - return res - } - slot.Channel.Topics = []store.Topic{} - if res := tx.Delete(&slot.Channel).Error; res != nil { - return res - } - slot.Channel = nil - if res := tx.Model(&slot).Update("revision", account.ChannelRevision + 1).Error; res != nil { - return res - } - if res := tx.Model(account).Update("channel_revision", account.ChannelRevision + 1).Error; res != nil { - return res - } - return nil - }) - if err != nil { - ErrResponse(w, http.StatusInternalServerError, err) - return - } - // cleanup file assets - go garbageCollect(account) + // cleanup file assets + go garbageCollect(account) + } else { + if _, member := cards[contact.Guid]; !member { + ErrResponse(w, http.StatusNotFound, errors.New("member channel not found")); + return + } + err = store.DB.Transaction(func(tx *gorm.DB) error { + if res := tx.Model(&slot.Channel).Association("Cards").Delete(contact); res != nil { + return res + } + if res := tx.Model(&slot.Channel).Update("detail_revision", account.ChannelRevision + 1).Error; res != nil { + return res + } + if res := tx.Model(&slot).Update("revision", account.ChannelRevision + 1).Error; res != nil { + return res + } + if res := tx.Model(&account).Update("channel_revision", account.ChannelRevision + 1).Error; res != nil { + return res + } + return nil + }) + if err != nil { + ErrResponse(w, http.StatusInternalServerError, err) + return + } + } SetStatus(account) for _, card := range cards { diff --git a/net/server/internal/store/schema.go b/net/server/internal/store/schema.go index 27d052fb..9564e6c2 100644 --- a/net/server/internal/store/schema.go +++ b/net/server/internal/store/schema.go @@ -51,9 +51,6 @@ type AccountToken struct { Account *Account } -// NOTE: card & app reference account by guid, all other tables by id -// because token lookup uses guid and is most common and wanted to avoid join -// int foreign key should be faster, so left other tables with id reference type Account struct { ID uint `gorm:"primaryKey;not null;unique;autoIncrement"` AccountDetailID uint `gorm:"not null"` diff --git a/net/server/transform/transform_vhd.sh b/net/server/transform/transform_vhd.sh new file mode 100755 index 00000000..d6556cb3 --- /dev/null +++ b/net/server/transform/transform_vhd.sh @@ -0,0 +1,2 @@ +#!/bin/bash +ffmpeg -i $1 -y -f mp4 -map_metadata -1 -vf scale=720:-2 -c:v libx264 -crf 23 -preset veryfast -c:a aac $2 diff --git a/net/server/transform/transform_vlq.sh b/net/server/transform/transform_vlq.sh new file mode 100755 index 00000000..f342731e --- /dev/null +++ b/net/server/transform/transform_vlq.sh @@ -0,0 +1,3 @@ +#!/bin/bash +ffmpeg -i $1 -y -f mp4 -map_metadata -1 -vf scale=320:-2 -c:v libx264 -crf 32 -preset veryfast -c:a aac $2 + diff --git a/net/server/transform/transform_vsd.sh b/net/server/transform/transform_vsd.sh new file mode 100755 index 00000000..1cfd2cea --- /dev/null +++ b/net/server/transform/transform_vsd.sh @@ -0,0 +1,2 @@ +#!/bin/bash +ffmpeg -i $1 -y -f mp4 -map_metadata -1 -vf scale=640:-2 -vcodec libx265 -crf 32 -preset veryfast -tag:v hvc1 -acodec aac $2 diff --git a/net/web/src/User/Conversation/AddTopic/VideoFile/VideoFile.jsx b/net/web/src/User/Conversation/AddTopic/VideoFile/VideoFile.jsx new file mode 100644 index 00000000..89bb74e1 --- /dev/null +++ b/net/web/src/User/Conversation/AddTopic/VideoFile/VideoFile.jsx @@ -0,0 +1,63 @@ +import React, { useEffect, useState, useRef } from 'react'; +import ReactPlayer from 'react-player' +import ReactResizeDetector from 'react-resize-detector'; +import { RightOutlined, LeftOutlined } from '@ant-design/icons'; +import { VideoFileWrapper, LabelInput } from './VideoFile.styled'; + +export function VideoFile({ url, onPosition }) { + + const [state, setState] = useState({ width: 0, height: 0 }); + const [playing, setPlaying] = useState(false); + const player = useRef(null); + const seek = useRef(0); + + const updateState = (value) => { + setState((s) => ({ ...s, ...value })); + } + + const onSeek = (offset) => { + if (player.current) { + let len = player.current.getDuration(); + seek.current += offset; + if (seek.current < 0 || seek.current >= len) { + seek.current = 0; + } + onPosition(seek.current); + player.current.seekTo(seek.current, 'seconds'); + setPlaying(true); + } + } + + const onPause = () => { + setPlaying(false); + } + + return ( + + + {({ width, height }) => { + if (width != state.width || height != state.height) { + updateState({ width, height }); + } + return onPause()} onPlay={() => onPause()} /> + }} + +
+
+
+
onSeek(-1)}> + +
+
+
+
onSeek(1)}> + +
+
+
+
+
+ ) +} + diff --git a/net/web/src/User/Conversation/AddTopic/VideoFile/VideoFile.styled.js b/net/web/src/User/Conversation/AddTopic/VideoFile/VideoFile.styled.js new file mode 100644 index 00000000..c32cddde --- /dev/null +++ b/net/web/src/User/Conversation/AddTopic/VideoFile/VideoFile.styled.js @@ -0,0 +1,38 @@ +import styled from 'styled-components'; + +export const VideoFileWrapper = styled.div` + position: relative; + height: 100%; + + .overlay { + position: absolute; + top: 0; + height: 100%; + display: flex; + align-items: center; + + .control { + width: 100%; + display: flex; + flex-direction: row; + + .left { + width: 50%; + display: flex; + justify-content: flex-begin; + } + + .right { + width: 50%; + display: flex; + justify-content: flex-end; + } + + .icon { + cursor: pointer; + } + } + } +`; + + diff --git a/net/web/src/User/Conversation/Conversation.jsx b/net/web/src/User/Conversation/Conversation.jsx index 8b32929c..276205a0 100644 --- a/net/web/src/User/Conversation/Conversation.jsx +++ b/net/web/src/User/Conversation/Conversation.jsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react' import { CloseOutlined, UserOutlined } from '@ant-design/icons'; import { useConversation } from './useConversation.hook'; import { Button, Checkbox, Modal, Spin } from 'antd' -import { ConversationWrapper, CloseButton, ListItem, BusySpin } from './Conversation.styled'; +import { ConversationWrapper, ConversationButton, CloseButton, ListItem, BusySpin } from './Conversation.styled'; import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from 'react-virtualized'; import { AddTopic } from './AddTopic/AddTopic'; import { VirtualList } from '../../VirtualList/VirtualList'; @@ -19,7 +19,10 @@ export function Conversation() { return (
-
{ state.handle }
+
+
+ actions.remove()}>Remove Conversation +
actions.close()} icon={} />
diff --git a/net/web/src/User/Conversation/Conversation.styled.jsx b/net/web/src/User/Conversation/Conversation.styled.jsx index 752653a9..21971a7b 100644 --- a/net/web/src/User/Conversation/Conversation.styled.jsx +++ b/net/web/src/User/Conversation/Conversation.styled.jsx @@ -34,6 +34,13 @@ export const ConversationWrapper = styled.div` padding-left: 16px; } + .buttons { + display: flex; + flex-direction: row; + margin-right: 32px; + align-items: center; + } + .close { font-size: 24px; color: white; @@ -49,6 +56,12 @@ export const ConversationWrapper = styled.div` } `; +export const ConversationButton = styled(Button)` + text-align: center; + margin-left: 8px; + margin-right: 8px; +` + export const CloseButton = styled(Button)` font-size: 24px; color: white; diff --git a/net/web/src/User/Conversation/useConversation.hook.js b/net/web/src/User/Conversation/useConversation.hook.js index 36e00935..d33802f4 100644 --- a/net/web/src/User/Conversation/useConversation.hook.js +++ b/net/web/src/User/Conversation/useConversation.hook.js @@ -25,6 +25,10 @@ export function useConversation() { close: () => { navigate('/user') }, + remove: async () => { + await conversation.actions.removeConversation(); + navigate('/user'); + } }; useEffect(() => { diff --git a/net/web/src/User/SideBar/Contacts/Channels/ChannelItem/ChannelLogo/ChannelLogo.jsx b/net/web/src/User/SideBar/Contacts/Channels/ChannelItem/ChannelLogo/ChannelLogo.jsx index 5a93bb69..d202efa3 100644 --- a/net/web/src/User/SideBar/Contacts/Channels/ChannelItem/ChannelLogo/ChannelLogo.jsx +++ b/net/web/src/User/SideBar/Contacts/Channels/ChannelItem/ChannelLogo/ChannelLogo.jsx @@ -32,8 +32,7 @@ export function ChannelLogo({ item }) { } setMembers(contacts); } - - }, [item, state]); + }, [item?.data?.channelDetail?.members, state]); const Logo = ({card}) => { if (card?.data?.cardProfile?.imageSet) { diff --git a/net/web/src/api/removeChannel.js b/net/web/src/api/removeChannel.js new file mode 100644 index 00000000..81cbd84a --- /dev/null +++ b/net/web/src/api/removeChannel.js @@ -0,0 +1,8 @@ +import { checkResponse, fetchWithTimeout } from './fetchUtil'; + +export async function removeChannel(token, channelId) { + + let channel = await fetchWithTimeout(`/content/channels/${channelId}?agent=${token}`, + { method: 'DELETE' }); + checkResponse(channel); +} diff --git a/net/web/src/api/removeContactChannel.js b/net/web/src/api/removeContactChannel.js new file mode 100644 index 00000000..4ee6f5a5 --- /dev/null +++ b/net/web/src/api/removeContactChannel.js @@ -0,0 +1,8 @@ +import { checkResponse, fetchWithTimeout } from './fetchUtil'; + +export async function removeContactChannel(server, token, channelId) { + + let channel = await fetchWithTimeout(`https://${server}/content/channels/${channelId}?contact=${token}`, + { method: 'DELETE' }); + checkResponse(channel); +} diff --git a/net/web/src/context/useCardContext.hook.js b/net/web/src/context/useCardContext.hook.js index 232cd666..8fb24569 100644 --- a/net/web/src/context/useCardContext.hook.js +++ b/net/web/src/context/useCardContext.hook.js @@ -8,6 +8,7 @@ import { getCards } from 'api/getCards'; import { getCardImageUrl } from 'api/getCardImageUrl'; import { getCardProfile } from 'api/getCardProfile'; import { getCardDetail } from 'api/getCardDetail'; +import { removeContactChannel } from 'api/removeContactChannel'; import { addContactChannelTopic } from 'api/addContactChannelTopic'; import { setCardConnecting, setCardConnected, setCardConfirmed } from 'api/setCardStatus'; import { getCardOpenMessage } from 'api/getCardOpenMessage'; @@ -207,6 +208,12 @@ export function useCardContext() { } return getCardImageUrl(access.current, cardId, card.data.profileRevision) }, + removeChannel: async (cardId, channelId) => { + let { cardProfile, cardDetail } = cards.current.get(cardId).data; + let token = cardProfile.guid + '.' + cardDetail.token; + let node = cardProfile.node; + await removeContactChannel(node, token, channelId); + }, addChannelTopic: async (cardId, channelId, message, assets) => { let { cardProfile, cardDetail } = cards.current.get(cardId).data; let token = cardProfile.guid + '.' + cardDetail.token; diff --git a/net/web/src/context/useChannelContext.hook.js b/net/web/src/context/useChannelContext.hook.js index 110ae2c0..538c1ce2 100644 --- a/net/web/src/context/useChannelContext.hook.js +++ b/net/web/src/context/useChannelContext.hook.js @@ -3,6 +3,7 @@ import { getChannels } from 'api/getChannels'; import { getChannelDetail } from 'api/getChannelDetail'; import { getChannelSummary } from 'api/getChannelSummary'; import { addChannel } from 'api/addChannel'; +import { removeChannel } from 'api/removeChannel'; import { addChannelTopic } from 'api/addChannelTopic'; import { getChannelTopics } from 'api/getChannelTopics'; import { getChannelTopic } from 'api/getChannelTopic'; @@ -88,6 +89,9 @@ export function useChannelContext() { addChannel: async (cards, subject, description) => { return await addChannel(access.current, cards, subject, description); }, + removeChannel: async (channelId) => { + return await removeChannel(access.current, channelId); + }, addChannelTopic: async (channelId, message, assets) => { await addChannelTopic(access.current, channelId, message, assets); }, diff --git a/net/web/src/context/useConversationContext.hook.js b/net/web/src/context/useConversationContext.hook.js index 2fa3b237..fd697229 100644 --- a/net/web/src/context/useConversationContext.hook.js +++ b/net/web/src/context/useConversationContext.hook.js @@ -30,6 +30,10 @@ export function useConversationContext() { if (cardId) { let deltaRevision = card.actions.getChannelRevision(cardId, channelId); + if (!deltaRevision) { + window.alert("This converstaion has been removed"); + return; + } if (curRevision != deltaRevision) { let delta = await card.actions.getChannelTopics(cardId, channelId, curRevision); for (let topic of delta) { @@ -163,7 +167,16 @@ export function useConversationContext() { else { return channel.actions.getChannelTopicAssetUrl(channelId, topicId, assetId); } - } + }, + removeConversation: async () => { + const { cardId, channelId } = conversationId.current; + if (cardId) { + return await card.actions.removeChannel(cardId, channelId); + } + else { + return await channel.actions.removeChannel(channelId); + } + }, } return { state, actions }