sending video attachments

This commit is contained in:
balzack 2024-12-14 15:40:22 -08:00
parent 21b4d1a4b6
commit 0c2de2e00d
13 changed files with 8255 additions and 5340 deletions

View File

@ -28,6 +28,7 @@
"react": "18.3.1",
"react-dom": "18.2.0",
"react-easy-crop": "^5.0.8",
"react-image-file-resizer": "^0.4.8",
"react-resize-detector": "^11.0.1",
"react-router-dom": "^6.26.0",
"redaxios": "^0.5.1",

View File

@ -245,9 +245,11 @@ export function useContent() {
addTopic: async (sealed: boolean, subject: string, contacts: string[]) => {
const content = app.state.session.getContent()
if (sealed) {
return await content.addChannel(true, 'sealed', { subject }, contacts)
const topic = await content.addChannel(true, 'sealed', { subject }, contacts)
return topic.id;
} else {
return await content.addChannel(false, 'superbasic', { subject }, contacts)
const topic = await content.addChannel(false, 'superbasic', { subject }, contacts)
return topic.id;
}
},
}

View File

@ -7,6 +7,7 @@ import { Divider, Text, Textarea, ActionIcon, Loader } from '@mantine/core'
import { Message } from '../message/Message';
import { modals } from '@mantine/modals'
import { ImageFile } from './imageFile/ImageFile';
import { VideoFile } from './videoFile/VideoFile';
const PAD_HEIGHT = (1024 - 64);
const LOAD_DEBOUNCE = 1000;
@ -26,6 +27,19 @@ export function Conversation() {
const [sending, setSending] = useState(false);
const { state, actions } = useConversation();
const attachImage = useRef({ click: ()=>{} } as HTMLInputElement);
const attachVideo = useRef({ click: ()=>{} } as HTMLInputElement);
const addImage = (image: File | undefined) => {
if (image) {
actions.addImage(image);
}
}
const addVideo = (video: File | undefined) => {
if (video) {
actions.addVideo(video);
}
}
const sendMessage = async () => {
if (!sending) {
@ -95,11 +109,16 @@ export function Conversation() {
const media = state.assets.map((asset, index: number) => {
if (asset.type === 'image') {
return <ImageFile key={index} source={asset.file} />
} else if (asset.type === 'video') {
return <VideoFile key={index} source={asset.file} thumbPosition={(position: number) => actions.setThumbPosition(index, position)} disabled={sending} />
} else {
return <div key={index}></div>
}
});
console.log(state.assets);
return (
<div className={classes.conversation}>
<div className={classes.header}>
@ -159,33 +178,34 @@ export function Conversation() {
</div>
<div className={classes.divider} />
<div className={classes.add}>
<input type='file' name="asset" accept="image/*" ref={attachImage} onChange={e => actions.addImage(e.target.files[0])} style={{display: 'none'}}/>
<input type='file' name="asset" accept="image/*" ref={attachImage} onChange={e => addImage(e.target?.files?.[0])} style={{display: 'none'}}/>
<input type='file' name="asset" accept="video/*" ref={attachVideo} onChange={e => addVideo(e.target?.files?.[0])} style={{display: 'none'}}/>
<div className={classes.files}>
{ media }
</div>
<Textarea className={classes.message} placeholder={state.strings.newMessage} value={state.message} onChange={(event) => actions.setMessage(event.currentTarget.value)} disabled={!state.detail || state.detail.locked} />
<Textarea className={classes.message} placeholder={state.strings.newMessage} value={state.message} onChange={(event) => actions.setMessage(event.currentTarget.value)} disabled={!state.detail || state.detail.locked || sending} />
<div className={classes.controls}>
<ActionIcon className={classes.attach} variant="light" disabled={!state.detail || state.detail.locked} onClick={() => attachImage.current.click()}>
<ActionIcon className={classes.attach} variant="light" disabled={!state.detail || state.detail.locked || sending} onClick={() => attachImage.current.click()}>
<IconCamera />
</ActionIcon>
<ActionIcon className={classes.attach} variant="light" disabled={!state.detail || state.detail.locked}>
<ActionIcon className={classes.attach} variant="light" disabled={!state.detail || state.detail.locked || sending} onClick={() => attachVideo.current.click()}>
<IconVideo />
</ActionIcon>
<ActionIcon className={classes.attach} variant="light" disabled={!state.detail || state.detail.locked}>
<ActionIcon className={classes.attach} variant="light" disabled={!state.detail || state.detail.locked || sending}>
<IconDisc />
</ActionIcon>
<ActionIcon className={classes.attach} variant="light" disabled={!state.detail || state.detail.locked}>
<ActionIcon className={classes.attach} variant="light" disabled={!state.detail || state.detail.locked || sending}>
<IconFile />
</ActionIcon>
<Divider size="sm" orientation="vertical" />
<ActionIcon className={classes.attach} variant="light" disabled={!state.detail || state.detail.locked}>
<ActionIcon className={classes.attach} variant="light" disabled={!state.detail || state.detail.locked || sending}>
<IconTextSize />
</ActionIcon>
<ActionIcon className={classes.attach} variant="light" disabled={!state.detail || state.detail.locked}>
<ActionIcon className={classes.attach} variant="light" disabled={!state.detail || state.detail.locked || sending}>
<IconTextColor />
</ActionIcon>
<div className={classes.send}>
<ActionIcon className={classes.attach} variant="light" disabled={!state.message || !state.detail || state.detail.locked} onClick={sendMessage} loading={sending}>
<ActionIcon className={classes.attach} variant="light" disabled={(!state.message && state.assets.length === 0) || !state.detail || state.detail.locked || sending} onClick={sendMessage} loading={sending}>
<IconSend />
</ActionIcon>
</div>

View File

@ -3,7 +3,7 @@
.thumb {
width: auto;
height: 64px;
height: 92px;
}
}

View File

@ -1,13 +1,81 @@
import { useState, useContext, useEffect, useRef } from 'react'
import { AppContext } from '../context/AppContext'
import { DisplayContext } from '../context/DisplayContext'
import { Focus, FocusDetail, Topic, Profile, Card, AssetSource, HostingMode, TransformType } from 'databag-client-sdk'
import { Focus, FocusDetail, Topic, Profile, Card, AssetType, AssetSource, HostingMode, TransformType } from 'databag-client-sdk'
import { ContextType } from '../context/ContextType'
import Resizer from "react-image-file-resizer";
import failed from '../images/failed.png'
const img = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII'
const IMAGE_SCALE_SIZE = (128 * 1024);
const GIF_TYPE = 'image/gif';
const WEBP_TYPE = 'image/webp';
const LOAD_DEBOUNCE = 1000;
function getImageThumb(file: File) {
return new Promise<string>((resolve, reject) => {
if ((file.type === GIF_TYPE || file.type === WEBP_TYPE) && file.size < IMAGE_SCALE_SIZE) {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = function () {
resolve(reader.result as string);
};
reader.onerror = function (error) {
reject();
};
}
else {
Resizer.imageFileResizer(file, 192, 192, 'JPEG', 50, 0,
uri => {
resolve(uri as string);
}, 'base64', 128, 128 );
}
});
}
function getVideoThumb(file: File, position: number) {
return new Promise<string>((resolve, reject) => {
const url = URL.createObjectURL(file);
var video = document.createElement("video");
var timeupdate = function (ev: any) {
video.removeEventListener("timeupdate", timeupdate);
video.pause();
setTimeout(() => {
var canvas = document.createElement("canvas");
if (!canvas) {
reject();
} else {
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));
}
const context = canvas.getContext("2d");
if (!context) {
reject();
} else {
context.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 = position;
video.play();
});
}
export function useConversation() {
const app = useContext(AppContext) as ContextType
const display = useContext(DisplayContext) as ContextType
@ -38,6 +106,13 @@ export function useConversation() {
setState((s) => ({ ...s, ...value }))
}
const updateAsset = (index, value) => {
setState((s) => {
s.assets[index] = { ...s.assets[index], ...value };
return { ...s };
});
}
useEffect(() => {
const { layout, strings } = display.state
updateState({ layout, strings })
@ -113,6 +188,9 @@ export function useConversation() {
setMessage: (message: string) => {
updateState({ message });
},
setThumbPosition: (index: number, position: number) => {
updateAsset(index, { position });
},
more: async () => {
const focus = app.state.focus;
if (focus) {
@ -129,33 +207,101 @@ export function useConversation() {
const focus = app.state.focus;
const sealed = state.detail?.sealed ? true : false;
if (focus) {
const subject = (assets: {assetId: string, appId: string}[]) => ({ text: state.message });
const sources = [] as AssetSource[];
const uploadAssets = state.assets.map(asset => {
if (asset.type === 'image') {
if (sealed) {
sources.push({ type: AssetType.Image, source: asset.file, transforms: [
{ type: TransformType.Thumb, appId: `it${sources.length}`, thumb: () => getImageThumb(asset.file) },
{ type: TransformType.Copy, appId: `ic${sources.length}` }
]});
return { encrypted: { type: 'image', thumb: `it${sources.length-1}`, parts: `ic${sources.length-1}` } };
} else {
sources.push({ type: AssetType.Image, source: asset.file, transforms: [
{ type: TransformType.Thumb, appId: `it${sources.length}` },
{ type: TransformType.Copy, appId: `ic${sources.length}` }
]});
return { image: { thumb: `it${sources.length-1}`, full: `ic${sources.length-1}` } };
}
} else if (asset.type === 'video') {
if (sealed) {
sources.push({ type: AssetType.Video, source: asset.file, transforms: [
{ type: TransformType.Thumb, appId: `vt${sources.length}`, thumb: () => getVideoThumb(asset.file, asset.position) },
{ type: TransformType.Copy, appId: `vc${sources.length}` }
]});
return { encrypted: { type: 'video', thumb: `vt${sources.length-1}`, parts: `vc${sources.length-1}` } };
} else {
sources.push({ type: AssetType.Video, source: asset.file, transforms: [
{ type: TransformType.Thumb, appId: `vt${sources.length}`, position: asset.position},
{ type: TransformType.HighQuality, appId: `vh${sources.length}` },
{ type: TransformType.LowQuality, appId: `vl${sources.length}` }
]});
return { video: { thumb: `vt${sources.length-1}`, hd: `vh${sources.length-1}`, lq: `vl${sources.length-1}` } };
}
} else if (asset.type === 'audio') {
if (sealed) {
sources.push({ type: AssetType.Audio, source: asset.file, transforms: [
{ type: TransformType.Copy, appId: `ac${sources.length}` }
]});
return { encrypted: { type: 'audio', parts: `ac${sources.length-1}` } };
} else {
sources.push({ type: AssetType.Video, source: asset.file, transforms: [
{ type: TransformType.Copy, appId: `ac${sources.length}` }
]});
return { audio: { full: `ac${sources.length-1}` } };
}
} else {
if (sealed) {
sources.push({ type: AssetType.Binary, source: asset.file, transforms: [
{ type: TransformType.Copy, appId: `bc${sources.length}` }
]});
return { encrypted: { type: 'binary', parts: `bc${sources.length-1}` } };
} else {
sources.push({ type: AssetType.Binary, source: asset.file, transforms: [
{ type: TransformType.Copy, appId: `bc${sources.length}` }
]});
return { binary: { data: `bc${sources.length-1}` } };
}
}
});
const subject = (uploaded: {assetId: string, appId: string}[]) => {
const assets = uploadAssets.map(asset => {
if(asset.encrypted) {
const type = asset.encrypted.type;
const thumb = uploaded.find(upload => upload.appId === asset.encrypted.thumb)?.assetId;
const parts = uploaded.find(upload => upload.appId === asset.encrypted.parts)?.assetId;
return { encrypted: { type, thumb, parts }};
} else if (asset.image) {
const thumb = uploaded.find(upload => upload.appId === asset.image.thumb)?.assetId;
const full = uploaded.find(upload => upload.appId === asset.image.full)?.assetId;
return { image: { thumb, full } };
} else if(asset.video) {
const thumb = uploaded.find(upload => upload.appId === asset.video.thumb)?.assetId;
const hd = uploaded.find(upload => upload.appId === asset.video.hd)?.assetId;
const lq = uploaded.find(upload => upload.appId === asset.video.lq)?.assetId;
return { video: { thumb, hd, lq } };
} else if (asset.audio) {
const full = uploaded.find(upload => upload.appId === asset.audio.full)?.assetId;
return { audio: { full } };
} else {
const data = uploaded.find(upload => upload.appId === asset.binary.data)?.assetId;
return { binary: { data } };
}
});
return { text: state.message, assets };
}
const progress = (precent: number) => {};
await focus.addTopic(sealed, sealed ? 'sealedtopic' : 'superbasictopic', subject, [], progress);
updateState({ message: '' });
await focus.addTopic(sealed, sealed ? 'sealedtopic' : 'superbasictopic', subject, sources, progress);
updateState({ message: '', assets: [] });
}
},
addImage: (file: File) => {
const type = 'image';
updateState({ assets: [ ...state.assets, { type, file } ]});
},
add: async (file: File) => {
const focus = app.state.focus;
if (focus) {
const asset = {
name: 'topic',
mimeType: 'image',
extension: 'jpg',
source: file,
transforms: [ {type: TransformType.Thumb, thumb: ()=>(img), appId: '1'}, {type: TransformType.Copy, appId: '2'}],
}
const topicId = await focus.addTopic(true, 'sealedtopic', (assets: {assetId: string, appId: string}[])=>{
console.log(assets);
return { text: 'almost done', assets: [{ encrypted: { type: 'image', thumb: '0', parts: '1' } }] };
}, [asset], (percent: number)=>{
console.log(percent);
});
}
addVideo: (file: File) => {
const type = 'video';
updateState({ assets: [ ...state.assets, { type, file } ]});
},
}

View File

@ -0,0 +1,26 @@
.asset {
position: relative;
margin-top: 16px;
height: 92px;
border-radius: 2px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
.left {
position: absolute;
left: 0;
}
.right {
position: absolute;
right: 0;
}
.thumb {
width: auto;
height: 64px;
}
}

View File

@ -0,0 +1,53 @@
import React, { useRef, useState } from 'react';
import { ActionIcon, Image } from '@mantine/core'
import { useVideoFile } from './useVideoFile.hook';
import classes from './VideoFile.module.css'
import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react'
export function VideoFile({ source, thumbPosition, disabled }: {source: File, thumbPosition: (position: number)=>void, disabled: boolean}) {
const { state, actions } = useVideoFile(source);
const [loaded, setLoaded] = useState(false);
const position = useRef(0);
const player = useRef();
const seek = (offset: number) => {
if (player.current) {
const len = player.current.duration;
if (len > 16) {
position.current += offset * Math.floor(len / 16);
}
else {
position.current += offset;
}
if (position.current < 0 || position.current >= len) {
position.current = 0;
}
thumbPosition(position.current);
player.current.currentTime = position.current;
player.current.play();
}
}
const onPause = () => {
player.current.pause();
}
return (
<div className={classes.asset}>
{ state.videoUrl && (
<video ref={player} muted onLoadedMetadata={() => setLoaded(true)} onPlay={onPause} src={state.videoUrl} width={'auto'} height={'100%'} playsinline="true" />
)}
{ loaded && !disabled && (
<ActionIcon className={classes.right} variant="light" onClick={() => seek(1)}>
<IconChevronRight />
</ActionIcon>
)}
{ loaded && !disabled && (
<ActionIcon className={classes.left} variant="light" onClick={() => seek(-1)}>
<IconChevronLeft />
</ActionIcon>
)}
</div>
);
}

View File

@ -0,0 +1,23 @@
import { useState, useEffect } from 'react'
export function useVideoFile(source: File) {
const [state, setState] = useState({
videoUrl: null,
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const updateState = (value: any) => {
setState((s) => ({ ...s, ...value }))
}
useEffect(() => {
const videoUrl = URL.createObjectURL(source);
updateState({ videoUrl });
return () => { URL.revokeObjectURL(videoUrl) };
}, [source]);
const actions = {
}
return { state, actions }
}

View File

@ -60,6 +60,12 @@
gap: 8px;
}
.incomplete {
margin-left: 72px;
margin-right: 32px;
margin-top: 8px;
}
.content {
display: flex;
flex-direction: row;

View File

@ -9,7 +9,6 @@ import { VideoAsset } from './videoAsset/VideoAsset';
import { BinaryAsset } from './binaryAsset/BinaryAsset';
import type { MediaAsset } from '../conversation/Conversation';
import { useMessage } from './useMessage.hook';
import failed from '../images/failed.png'
import { IconForbid, IconTrash, IconEdit, IconAlertSquareRounded, IconChevronLeft, IconChevronRight, IconFileAlert } from '@tabler/icons-react';
import { useResizeDetector } from 'react-resize-detector';
@ -126,7 +125,9 @@ export function Message({ topic, card, profile, host }: { topic: Topic, card: Ca
</div>
)}
{ !locked && media.length > 0 && transform === 'incomplete' && (
<Skeleton height={64} circle mb="xl" />
<div className={classes.incomplete}>
<Skeleton height={64} circle mb="xl" />
</div>
)}
{ !locked && media.length > 0 && transform !== 'complete' && transform !== 'incomplete' && (
<div className={classes.failed}>

View File

@ -26,7 +26,7 @@ export function useMessage() {
const now = Math.floor((new Date()).getTime() / 1000)
const date = new Date(created * 1000);
const offset = now - created;
if(offset < 86400) {
if(offset < 43200) {
if (state.timeFormat === '12h') {
return date.toLocaleTimeString("en-US", {hour: 'numeric', minute:'2-digit'});
}

File diff suppressed because it is too large Load Diff

View File

@ -450,8 +450,9 @@ export class FocusModule implements Focus {
transforms.push('icopy;photo');
transformMap.set('icopy;photo', transform.appId);
} else if (transform.type === TransformType.Thumb && asset.type === AssetType.Video) {
transforms.push('vthumb;video');
transformMap.set('vthumb;video', transform.appId);
const transformKey = `vthumb;video;${ transform.position ? transform.position : 0}`;
transforms.push(transformKey);
transformMap.set(transformKey, transform.appId);
} else if (transform.type === TransformType.Copy && asset.type === AssetType.Video) {
transforms.push('vcopy;video');
transformMap.set('vcopy;video', transform.appId);
@ -487,7 +488,7 @@ export class FocusModule implements Focus {
}
if (transformMap.has(transformAsset.transform)) {
const appId = transformMap.get(transformAsset.transform) || '' //or to make build happy
appAsset.push({appId, assetId: transformAsset.assetId });
appAsset.push({appId, assetId: assetItem.assetId });
assetItems.push(assetItem);
}
}