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()} />
+ }}
+
+
+
+ )
+}
+
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 (
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 }