diff --git a/app/mobile/src/context/useConversationContext.hook.js b/app/mobile/src/context/useConversationContext.hook.js index 719c0488..71b1d327 100644 --- a/app/mobile/src/context/useConversationContext.hook.js +++ b/app/mobile/src/context/useConversationContext.hook.js @@ -159,6 +159,7 @@ export function useConversationContext() { clearConversation: async () => { conversationId.current = null; reset.current = true; + await sync(); }, setChannelSubject: async (type, subject) => { const { cardId, channelId } = conversationId.current || {}; @@ -394,6 +395,13 @@ export function useConversationContext() { } } + const getTopicAssetUrl = (cardId, channelId, topicId, assetId) => { + if (cardId) { + return card.actions.getTopicAssetUrl(cardId, channelId, topicId, assetId); + } + return channel.actions.getTopicAssetUrl(channelId, topicId, assetId); + } + const mapTopicEntry = (entry) => { return { topicId: entry.id, diff --git a/app/mobile/src/session/conversation/Conversation.jsx b/app/mobile/src/session/conversation/Conversation.jsx index b58926d6..1d1a4fdb 100644 --- a/app/mobile/src/session/conversation/Conversation.jsx +++ b/app/mobile/src/session/conversation/Conversation.jsx @@ -1,5 +1,5 @@ -import { useEffect, useState, useContext } from 'react'; -import { View, Text, TouchableOpacity } from 'react-native'; +import { useRef, useEffect, useState, useContext } from 'react'; +import { FlatList, View, Text, TouchableOpacity } from 'react-native'; import { ConversationContext } from 'context/ConversationContext'; import { useConversation } from './useConversation.hook'; import { styles } from './Conversation.styled'; @@ -7,12 +7,37 @@ import { Colors } from 'constants/Colors'; import Ionicons from 'react-native-vector-icons/AntDesign'; import { Logo } from 'utils/Logo'; import { AddTopic } from './addTopic/AddTopic'; +import { TopicItem } from './topicItem/TopicItem'; export function Conversation({ navigation, cardId, channelId, closeConversation, openDetails }) { const [ready, setReady] = useState(false); const conversation = useContext(ConversationContext); const { state, actions } = useConversation(); + const ref = useRef(); + + const latch = () => { + if (!state.momentum) { + actions.latch(); + ref.current.scrollToIndex({ animated: true, index: 0 }); + } + } + + const updateTopic = async () => { + try { + await actions.updateTopic(); + actions.hideEdit(); + } + catch (err) { + console.log(err); + Alert.alert( + 'Failed to Update Message', + 'Please try again.', + ) + } + } + + const noop = () => {}; useEffect(() => { if (navigation) { @@ -23,8 +48,8 @@ export function Conversation({ navigation, cardId, channelId, closeConversation, ), headerRight: () => ( - - + + ), }); @@ -58,7 +83,18 @@ export function Conversation({ navigation, cardId, channelId, closeConversation, )} - Conversation + actions.setFocus(item.topicId)} hosting={state.host == null} + sealed={state.sealed} sealKey={state.sealKey} + remove={actions.removeTopic} update={actions.editTopic} block={actions.blockTopic} + report={actions.reportTopic} />} + keyExtractor={item => item.topicId} + /> diff --git a/app/mobile/src/session/conversation/Conversation.styled.js b/app/mobile/src/session/conversation/Conversation.styled.js index f74543ee..15dba9cb 100644 --- a/app/mobile/src/session/conversation/Conversation.styled.js +++ b/app/mobile/src/session/conversation/Conversation.styled.js @@ -8,6 +8,7 @@ export const styles = StyleSheet.create({ flexDirection: 'row', justifyContent: 'center', alignItems: 'center', + flexShrink: 1, }, header: { display: 'flex', @@ -37,6 +38,7 @@ export const styles = StyleSheet.create({ }, titlebutton: { paddingRight: 8, + width: 32, }, headerclose: { flexGrow: 1, @@ -46,16 +48,19 @@ export const styles = StyleSheet.create({ display: 'flex', flexDirection: 'column', flexGrow: 1, + height: '100%', }, thread: { display: 'flex', flexDirection: 'column', flexGrow: 1, + flexShrink: 1, }, messages: { display: 'flex', flexDirection: 'column', flexGrow: 1, + flexShrink: 1, }, }); diff --git a/app/mobile/src/session/conversation/topicItem/TopicItem.jsx b/app/mobile/src/session/conversation/topicItem/TopicItem.jsx new file mode 100644 index 00000000..99be1242 --- /dev/null +++ b/app/mobile/src/session/conversation/topicItem/TopicItem.jsx @@ -0,0 +1,214 @@ +import { KeyboardAvoidingView, FlatList, View, Text, TextInput, Modal, Image, Alert } from 'react-native'; +import { TouchableOpacity } from 'react-native-gesture-handler'; +import { useTopicItem } from './useTopicItem.hook'; +import { styles } from './TopicItem.styled'; +import Colors from 'constants/Colors'; +import AntIcons from 'react-native-vector-icons/AntDesign'; +import MatIcons from 'react-native-vector-icons/MaterialCommunityIcons'; +import avatar from 'images/avatar.png'; +import { VideoThumb } from './videoThumb/VideoThumb'; +import { AudioThumb } from './audioThumb/AudioThumb'; +import { ImageThumb } from './imageThumb/ImageThumb'; +import { ImageAsset } from './imageAsset/ImageAsset'; +import { AudioAsset } from './audioAsset/AudioAsset'; +import { VideoAsset } from './videoAsset/VideoAsset'; + +export function TopicItem({ item, focused, focus, hosting, sealed, sealKey, remove, update, block, report }) { + + const { state, actions } = useTopicItem(item, hosting, remove, sealed, sealKey); + + const erase = () => { + Alert.alert( + "Removing Message", + "Confirm?", + [ + { text: "Cancel", + onPress: () => {}, + }, + { text: "Remove", + onPress: async () => { + try { + await remove(item.topicId); + } + catch (err) { + console.log(err); + Alert.alert( + 'Failed to Remove Message', + 'Please try again.' + ) + } + }, + } + ] + ); + } + + const reportMessage = () => { + Alert.alert( + "Report Message", + "Confirm?", + [ + { text: "Cancel", + onPress: () => {}, + }, + { text: "Report", + onPress: async () => { + try { + await report(item.topicId); + } + catch (err) { + console.log(err); + Alert.alert( + 'Failed to Report Message', + 'Please try again.' + ) + } + }, + } + ] + ); + } + + const hideMessage = () => { + Alert.alert( + "Blocking Message", + "Confirm?", + [ + { text: "Cancel", + onPress: () => {}, + }, + { text: "Block", + onPress: async () => { + try { + await block(item.topicId); + } + catch (err) { + console.log(err); + Alert.alert( + 'Failed to Block Message', + 'Please try again.' + ) + } + }, + } + ] + ); + } + + + const renderAsset = (asset) => { + return ( + + { asset.item.image && ( + + )} + { asset.item.video && ( + + )} + { asset.item.audio && ( + + )} + + ) + } + + const renderThumb = (thumb) => { + return ( + + { thumb.item.image && ( + actions.showCarousel(thumb.index)} /> + )} + { thumb.item.video && ( + actions.showCarousel(thumb.index)} /> + )} + { thumb.item.audio && ( + actions.showCarousel(thumb.index)} /> + )} + + ); + } + + return ( + + + + { state.logo !== 'avatar' && state.logo && ( + + )} + { (state.logo === 'avatar' || !state.logo) && ( + + )} + { state.name } + { state.timestamp } + + { state.status === 'confirmed' && ( + <> + { state.transform === 'complete' && state.assets && ( + + )} + { state.transform === 'incomplete' && ( + + + + )} + { state.transform === 'error' && ( + + + + )} + { state.message && !state.sealed && ( + { state.message } + )} + { state.sealed && ( + sealed message + )} + + )} + { state.status !== 'confirmed' && ( + + + + )} + + { focused && ( + + { state.editable && ( + update(item.topicId, state.editData)}> + + + )} + { !state.editable && ( + + + + )} + { !state.editable && ( + + + + )} + { state.deletable && ( + + + + )} + + )} + + + + + + ); +} diff --git a/app/mobile/src/session/conversation/topicItem/TopicItem.styled.js b/app/mobile/src/session/conversation/topicItem/TopicItem.styled.js new file mode 100644 index 00000000..ba74a4bc --- /dev/null +++ b/app/mobile/src/session/conversation/topicItem/TopicItem.styled.js @@ -0,0 +1,149 @@ +import { StyleSheet } from 'react-native'; +import { Colors } from 'constants/Colors'; + +export const styles = StyleSheet.create({ + wrapper: { + paddingTop: 8, + }, + item: { + borderTopWidth: 1, + borderColor: Colors.white, + paddingTop: 8, + }, + header: { + display: 'flex', + flexDirection: 'row', + paddingLeft: 16, + }, + name: { + paddingLeft: 8, + }, + timestamp: { + paddingLeft: 8, + fontSize: 11, + paddingTop: 2, + color: Colors.grey, + }, + carousel: { + paddingLeft: 52, + marginTop: 4, + marginBottom: 4, + }, + modal: { + width: '100%', + height: '100%', + backgroundColor: 'rgba(0, 0, 0, 0.9)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + frame: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + height: '100%', + }, + status: { + paddingLeft: 52, + }, + sealed: { + paddingRight: 16, + paddingLeft: 52, + color: Colors.grey, + fontStyle: 'italic', + }, + focused: { + position: 'absolute', + top: 0, + right: 16, + display: 'flex', + flexDirection: 'row', + backgroundColor: 'rgba(0, 0, 0, 0.3)', + paddingTop: 2, + paddingBottom: 2, + borderRadius: 4, + paddingLeft: 4, + paddingRight: 4, + alignItems: 'center', + }, + icon: { + paddingLeft: 8, + paddingRight: 8, + }, + message: { + paddingRight: 16, + paddingLeft: 52, + color: Colors.fontColor, + }, + save: { + padding: 8, + borderRadius: 4, + backgroundColor: Colors.primary, + width: 72, + display: 'flex', + alignItems: 'center', + }, + saveText: { + color: Colors.white, + }, + cancel: { + borderWidth: 1, + borderColor: Colors.lightgrey, + borderRadius: 4, + padding: 8, + marginRight: 8, + width: 72, + display: 'flex', + alignItems: 'center', + }, + inputField: { + width: '100%', + borderWidth: 1, + borderColor: Colors.lightgrey, + borderRadius: 4, + padding: 8, + marginBottom: 8, + maxHeight: 92, + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + input: { + fontSize: 14, + flexGrow: 1, + }, + editControls: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-end', + }, + editWrapper: { + display: 'flex', + width: '100%', + height: '100%', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(52, 52, 52, 0.8)' + }, + editContainer: { + backgroundColor: Colors.formBackground, + padding: 16, + width: '80%', + maxWidth: 400, + }, + editHeader: { + fontSize: 18, + paddingBottom: 16, + }, + editMembers: { + width: '100%', + borderWidth: 1, + borderColor: Colors.lightgrey, + borderRadius: 4, + marginBottom: 8, + height: 250, + }, +}) + diff --git a/app/mobile/src/session/conversation/topicItem/audioAsset/AudioAsset.jsx b/app/mobile/src/session/conversation/topicItem/audioAsset/AudioAsset.jsx new file mode 100644 index 00000000..c0ce1d33 --- /dev/null +++ b/app/mobile/src/session/conversation/topicItem/audioAsset/AudioAsset.jsx @@ -0,0 +1,38 @@ +import { Image, View, Text, TouchableOpacity } from 'react-native'; +import { useRef } from 'react'; +import Colors from 'constants/Colors'; +import Video from 'react-native-video'; +import { useAudioAsset } from './useAudioAsset.hook'; +import { styles } from './AudioAsset.styled'; +import Icons from 'react-native-vector-icons/MaterialCommunityIcons'; +import audio from 'images/audio.png'; + +export function AudioAsset({ topicId, asset, dismiss }) { + + const { state, actions } = useAudioAsset(topicId, asset); + + const player = useRef(null); + + return ( + + + { asset.label } + { !state.playing && state.loaded && ( + + + + )} + { state.playing && state.loaded && ( + + + + )} + + + + + ); +} + diff --git a/app/mobile/src/session/conversation/topicItem/audioAsset/AudioAsset.styled.js b/app/mobile/src/session/conversation/topicItem/audioAsset/AudioAsset.styled.js new file mode 100644 index 00000000..00d69e82 --- /dev/null +++ b/app/mobile/src/session/conversation/topicItem/audioAsset/AudioAsset.styled.js @@ -0,0 +1,40 @@ +import { StyleSheet } from 'react-native'; +import { Colors } from 'constants/Colors'; + +export const styles = StyleSheet.create({ + container: { + position: 'relative', + borderRadius: 8, + backgroundColor: Colors.formBackground, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + control: { + position: 'absolute', + paddingRight: 8, + paddingTop: 4, + }, + label: { + position: 'absolute', + textAlign: 'center', + fontSize: 20, + paddingTop: 8, + top: 0, + paddingLeft: 48, + paddingRight: 48, + }, + close: { + position: 'absolute', + top: 0, + right: 0, + paddingTop: 4, + paddingBottom: 4, + paddingLeft: 8, + paddingRight: 8, + }, + player: { + display: 'none', + }, +}) + diff --git a/app/mobile/src/session/conversation/topicItem/audioAsset/useAudioAsset.hook.js b/app/mobile/src/session/conversation/topicItem/audioAsset/useAudioAsset.hook.js new file mode 100644 index 00000000..079e8766 --- /dev/null +++ b/app/mobile/src/session/conversation/topicItem/audioAsset/useAudioAsset.hook.js @@ -0,0 +1,59 @@ +import { useState, useRef, useEffect, useContext } from 'react'; +import { ConversationContext } from 'context/ConversationContext'; +import { Image } from 'react-native'; +import { useWindowDimensions } from 'react-native'; + +export function useAudioAsset(topicId, asset) { + + const [state, setState] = useState({ + width: 1, + height: 1, + url: null, + playing: false, + loaded: false, + }); + + const closing = useRef(null); + const conversation = useContext(ConversationContext); + const dimensions = useWindowDimensions(); + + const updateState = (value) => { + setState((s) => ({ ...s, ...value })); + } + + useEffect(() => { + const frameRatio = dimensions.width / dimensions.height; + if (frameRatio > 1) { + //height constrained + const height = 0.9 * dimensions.height; + const width = height; + updateState({ width, height }); + } + else { + //width constrained + const width = 0.9 * dimensions.width; + const height = width; + updateState({ width, height }); + } + }, [dimensions]); + + useEffect(() => { + const url = conversation.actions.getTopicAssetUrl(topicId, asset.full); + updateState({ url }); + }, [topicId, conversation, asset]); + + const actions = { + play: () => { + updateState({ playing: true }); + }, + pause: () => { + updateState({ playing: false }); + }, + loaded: () => { + updateState({ loaded: true }); + } + }; + + return { state, actions }; +} + diff --git a/app/mobile/src/session/conversation/topicItem/audioThumb/AudioThumb.jsx b/app/mobile/src/session/conversation/topicItem/audioThumb/AudioThumb.jsx new file mode 100644 index 00000000..03210f5d --- /dev/null +++ b/app/mobile/src/session/conversation/topicItem/audioThumb/AudioThumb.jsx @@ -0,0 +1,22 @@ +import { View, Text, Image } from 'react-native'; +import { TouchableOpacity } from 'react-native-gesture-handler'; +import { styles } from './AudioThumb.styled'; +import Colors from 'constants/Colors'; +import audio from 'images/audio.png'; + +export function AudioThumb({ topicId, asset, onAssetView }) { + + return ( + + + { asset.label && ( + + { asset.label } + + )} + + ); + +} + + diff --git a/app/mobile/src/session/conversation/topicItem/audioThumb/AudioThumb.styled.js b/app/mobile/src/session/conversation/topicItem/audioThumb/AudioThumb.styled.js new file mode 100644 index 00000000..695265d2 --- /dev/null +++ b/app/mobile/src/session/conversation/topicItem/audioThumb/AudioThumb.styled.js @@ -0,0 +1,18 @@ +import { StyleSheet } from 'react-native'; +import { Colors } from 'constants/Colors'; + +export const styles = StyleSheet.create({ + overlay: { + top: 0, + width: '100%', + display: 'flex', + alignItems: 'center', + position: 'absolute', + paddingRight: 16, + maxHeight: 50, + }, + label: { + textOverlay: 'center', + }, +}) + diff --git a/app/mobile/src/session/conversation/topicItem/imageAsset/ImageAsset.jsx b/app/mobile/src/session/conversation/topicItem/imageAsset/ImageAsset.jsx new file mode 100644 index 00000000..fb3df394 --- /dev/null +++ b/app/mobile/src/session/conversation/topicItem/imageAsset/ImageAsset.jsx @@ -0,0 +1,26 @@ +import { View, Image, ActivityIndicator, TouchableOpacity } from 'react-native'; +import { useImageAsset } from './useImageAsset.hook'; +import { styles } from './ImageAsset.styled'; +import Colors from 'constants/Colors'; + +export function ImageAsset({ topicId, asset, dismiss }) { + const { state, actions } = useImageAsset(topicId, asset); + + return ( + + + { state.failed && ( + + + + )} + { !state.loaded && !state.failed && ( + + + + )} + + ); +} + diff --git a/app/mobile/src/session/conversation/topicItem/imageAsset/ImageAsset.styled.js b/app/mobile/src/session/conversation/topicItem/imageAsset/ImageAsset.styled.js new file mode 100644 index 00000000..982a04c1 --- /dev/null +++ b/app/mobile/src/session/conversation/topicItem/imageAsset/ImageAsset.styled.js @@ -0,0 +1,25 @@ +import { StyleSheet } from 'react-native'; +import { Colors } from 'constants/Colors'; + +export const styles = StyleSheet.create({ + container: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + loading: { + position: 'absolute', + }, + overlay: { + marginRight: 16, + position: 'absolute', + bottom: 0, + right: 0, + padding: 2, + borderTopLeftRadius: 4, + backgroundColor: Colors.white, + borderWidth: 1, + borderColor: Colors.divider, + }, +}) + diff --git a/app/mobile/src/session/conversation/topicItem/imageAsset/useImageAsset.hook.js b/app/mobile/src/session/conversation/topicItem/imageAsset/useImageAsset.hook.js new file mode 100644 index 00000000..566eb2f1 --- /dev/null +++ b/app/mobile/src/session/conversation/topicItem/imageAsset/useImageAsset.hook.js @@ -0,0 +1,65 @@ +import { useState, useRef, useEffect, useContext } from 'react'; +import { ConversationContext } from 'context/ConversationContext'; +import { Image } from 'react-native'; +import { useWindowDimensions } from 'react-native'; + +export function useImageAsset(topicId, asset) { + + const [state, setState] = useState({ + frameWidth: 1, + frameHeight: 1, + imageRatio: 1, + imageWidth: 1, + imageHeight: 1, + url: null, + loaded: false, + failed: false, + }); + + const conversation = useContext(ConversationContext); + const dimensions = useWindowDimensions(); + + const updateState = (value) => { + setState((s) => ({ ...s, ...value })); + } + + useEffect(() => { + if (state.loaded) { + const frameRatio = state.frameWidth / state.frameHeight; + if (frameRatio > state.imageRatio) { + //height constrained + const height = 0.9 * state.frameHeight; + const width = height * state.imageRatio; + updateState({ imageWidth: width, imageHeight: height }); + } + else { + //width constrained + const width = 0.9 * state.frameWidth; + const height = width / state.imageRatio; + updateState({ imageWidth: width, imageHeight: height }); + } + } + }, [state.frameWidth, state.frameHeight, state.imageRatio, state.loaded]); + + useEffect(() => { + updateState({ frameWidth: dimensions.width, frameHeight: dimensions.height }); + }, [dimensions]); + + useEffect(() => { + const url = conversation.actions.getTopicAssetUrl(topicId, asset.full); + updateState({ url }); + }, [topicId, conversation, asset]); + + const actions = { + loaded: (e) => { + const { width, height } = e.nativeEvent.source; + updateState({ loaded: true, imageRatio: width / height }); + }, + failed: () => { + updateState({ failed: true }); + }, + }; + + return { state, actions }; +} + diff --git a/app/mobile/src/session/conversation/topicItem/imageThumb/ImageThumb.jsx b/app/mobile/src/session/conversation/topicItem/imageThumb/ImageThumb.jsx new file mode 100644 index 00000000..7c7572a6 --- /dev/null +++ b/app/mobile/src/session/conversation/topicItem/imageThumb/ImageThumb.jsx @@ -0,0 +1,19 @@ +import { Image } from 'react-native'; +import { TouchableOpacity } from 'react-native-gesture-handler'; +import { useImageThumb } from './useImageThumb.hook'; +import { styles } from './ImageThumb.styled'; +import Colors from 'constants/Colors'; + +export function ImageThumb({ topicId, asset, onAssetView }) { + const { state, actions } = useImageThumb(topicId, asset); + + return ( + + + + ); + +} + + diff --git a/app/mobile/src/session/conversation/topicItem/imageThumb/ImageThumb.styled.js b/app/mobile/src/session/conversation/topicItem/imageThumb/ImageThumb.styled.js new file mode 100644 index 00000000..9c879c72 --- /dev/null +++ b/app/mobile/src/session/conversation/topicItem/imageThumb/ImageThumb.styled.js @@ -0,0 +1,17 @@ +import { StyleSheet } from 'react-native'; +import { Colors } from 'constants/Colors'; + +export const styles = StyleSheet.create({ + overlay: { + marginRight: 16, + position: 'absolute', + bottom: 0, + right: 0, + padding: 2, + borderTopLeftRadius: 4, + backgroundColor: Colors.white, + borderWidth: 1, + borderColor: Colors.divider, + }, +}) + diff --git a/app/mobile/src/session/conversation/topicItem/imageThumb/useImageThumb.hook.js b/app/mobile/src/session/conversation/topicItem/imageThumb/useImageThumb.hook.js new file mode 100644 index 00000000..d6992564 --- /dev/null +++ b/app/mobile/src/session/conversation/topicItem/imageThumb/useImageThumb.hook.js @@ -0,0 +1,32 @@ +import { useState, useRef, useEffect, useContext } from 'react'; +import { ConversationContext } from 'context/ConversationContext'; +import { Image } from 'react-native'; + +export function useImageThumb(topicId, asset) { + + const [state, setState] = useState({ + ratio: 1, + url: null, + }); + + const conversation = useContext(ConversationContext); + + const updateState = (value) => { + setState((s) => ({ ...s, ...value })); + } + + useEffect(() => { + const url = conversation.actions.getTopicAssetUrl(topicId, asset.thumb); + updateState({ url }); + }, [topicId, conversation, asset]); + + const actions = { + loaded: (e) => { + const { width, height } = e.nativeEvent.source; + updateState({ ratio: width / height }); + }, + }; + + return { state, actions }; +} + diff --git a/app/mobile/src/session/conversation/topicItem/useTopicItem.hook.js b/app/mobile/src/session/conversation/topicItem/useTopicItem.hook.js new file mode 100644 index 00000000..46cf6104 --- /dev/null +++ b/app/mobile/src/session/conversation/topicItem/useTopicItem.hook.js @@ -0,0 +1,177 @@ +import { useState, useEffect, useContext } from 'react'; +import { ConversationContext } from 'context/ConversationContext'; +import { CardContext } from 'context/CardContext'; +import { ProfileContext } from 'context/ProfileContext'; +import moment from 'moment'; +import { useWindowDimensions } from 'react-native'; +import Colors from 'constants/Colors'; +import { getCardByGuid } from 'context/cardUtil'; + +export function useTopicItem(item, hosting, remove, sealed, sealKey) { + + const [state, setState] = useState({ + name: null, + nameSet: null, + known: null, + logo: null, + timestamp: null, + message: null, + carousel: false, + carouselIndex: 0, + width: null, + height: null, + activeId: null, + fontSize: 14, + fontColor: Colors.text, + editable: false, + deletable: false, + assets: [], + }); + + const conversation = useContext(ConversationContext); + const profile = useContext(ProfileContext); + const card = useContext(CardContext); + const dimensions = useWindowDimensions(); + + const updateState = (value) => { + setState((s) => ({ ...s, ...value })); + } + + useEffect(() => { + updateState({ width: dimensions.width, height: dimensions.height }); + }, [dimensions]); + + useEffect(() => { + const { topicId, detail, unsealedDetail } = item; + const { guid, dataType, data, status, transform } = detail; + + let name, nameSet, known, logo; + const identity = profile.state?.identity; + if (guid === identity.guid) { + known = true; + if (identity.name) { + name = identity.name; + } + else { + name = `${identity.handle}@${identity.node}`; + } + const img = profile.state.imageUrl; + if (img) { + logo = img; + } + else { + logo = 'avatar'; + } + } + else { + const contact = getCardByGuid(card.state.cards, guid)?.card; + if (contact) { + logo = contact.profile?.imageSet ? card.actions.getCardImageUrl(contact.cardId) : null; + + known = true; + if (contact.profile.name) { + name = contact.profile.name; + nameSet = true; + } + else { + name = `${contact.profile.handle}@${contact.profile.node}`; + nameSet = false; + } + } + else { + name = "unknown"; + nameSet = false; + known = false; + logo = null; + } + } + + let parsed, sealed, message, assets, fontSize, fontColor; + if (dataType === 'superbasictopic') { + try { + sealed = false; + parsed = JSON.parse(data); + message = parsed.text; + assets = parsed.assets; + if (parsed.textSize === 'small') { + fontSize = 10; + } + else if (parsed.textSize === 'large') { + fontSize = 20; + } + else { + fontSize = 14; + } + if (parsed.textColor) { + fontColor = parsed.textColor; + } + else { + fontColor = Colors.text; + } + } + catch (err) { + console.log(err); + } + } + else if (dataType === 'sealedtopic') { + if (unsealedDetail) { + sealed = false; + parsed = unsealedDetail.message; + message = parsed?.text; + if (parsed?.textSize === 'small') { + fontSize = 10; + } + else if (parsed?.textSize === 'large') { + fontSize = 20; + } + else { + fontSize = 14; + } + if (parsed?.textColor) { + fontColor = parsed?.textColor; + } + else { + fontColor = Colors.text; + } + } + else { + conversation.actions.unsealTopic(topicId, sealKey); + sealed = true; + } + } + + let timestamp; + const date = new Date(item.detail.created * 1000); + const now = new Date(); + const offset = now.getTime() - date.getTime(); + if(offset < 86400000) { + timestamp = moment(date).format('h:mma'); + } + else if (offset < 31449600000) { + timestamp = moment(date).format('M/DD'); + } + else { + timestamp = moment(date).format('M/DD/YYYY'); + } + + const editable = detail.guid === identity.guid && parsed; + const deletable = editable || hosting; + + updateState({ logo, name, nameSet, known, sealed, message, fontSize, fontColor, timestamp, transform, status, assets, deletable, editable, editData: parsed, editMessage: message }); + }, [sealKey, card, item]); + + const actions = { + showCarousel: (index) => { + updateState({ carousel: true, carouselIndex: index }); + }, + hideCarousel: () => { + updateState({ carousel: false }); + }, + setActive: (activeId) => { + updateState({ activeId }); + }, + }; + + return { state, actions }; +} + diff --git a/app/mobile/src/session/conversation/topicItem/videoAsset/VideoAsset.jsx b/app/mobile/src/session/conversation/topicItem/videoAsset/VideoAsset.jsx new file mode 100644 index 00000000..c8c2fa78 --- /dev/null +++ b/app/mobile/src/session/conversation/topicItem/videoAsset/VideoAsset.jsx @@ -0,0 +1,45 @@ +import { ActivityIndicator, Image, View, TouchableOpacity } from 'react-native'; +import Colors from 'constants/Colors'; +import Video from 'react-native-video'; +import { useVideoAsset } from './useVideoAsset.hook'; +import { styles } from './VideoAsset.styled'; +import Icons from 'react-native-vector-icons/MaterialCommunityIcons'; + +export function VideoAsset({ topicId, asset, dismiss }) { + + const { state, actions } = useVideoAsset(topicId, asset); + + return ( + + + + { !state.loaded && ( + + + + )} + + ); +} + diff --git a/app/mobile/src/session/conversation/topicItem/videoAsset/VideoAsset.styled.js b/app/mobile/src/session/conversation/topicItem/videoAsset/VideoAsset.styled.js new file mode 100644 index 00000000..49720062 --- /dev/null +++ b/app/mobile/src/session/conversation/topicItem/videoAsset/VideoAsset.styled.js @@ -0,0 +1,32 @@ +import { StyleSheet } from 'react-native'; +import { Colors } from 'constants/Colors'; + +export const styles = StyleSheet.create({ + container: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + overlay: { + position: 'absolute', + backgroundColor: 'rgba(0, 0, 0, 0.4)', + }, + control: { + position: 'absolute', + paddingRight: 8, + paddingTop: 4, + }, + close: { + position: 'absolute', + top: 0, + right: 0, + paddingTop: 4, + paddingBottom: 4, + paddingLeft: 8, + paddingRight: 8, + }, + loading: { + position: 'absolute', + }, +}) + diff --git a/app/mobile/src/session/conversation/topicItem/videoAsset/useVideoAsset.hook.js b/app/mobile/src/session/conversation/topicItem/videoAsset/useVideoAsset.hook.js new file mode 100644 index 00000000..226c4279 --- /dev/null +++ b/app/mobile/src/session/conversation/topicItem/videoAsset/useVideoAsset.hook.js @@ -0,0 +1,78 @@ +import { useState, useRef, useEffect, useContext } from 'react'; +import { ConversationContext } from 'context/ConversationContext'; +import { Image } from 'react-native'; +import { useWindowDimensions } from 'react-native'; + +export function useVideoAsset(topicId, asset) { + + const [state, setState] = useState({ + frameWidth: 1, + frameHeight: 1, + videoRatio: 1, + width: 1, + url: null, + playing: false, + loaded: false, + controls: false, + display: { display: 'none' }, + }); + + const controls = useRef(null); + const conversation = useContext(ConversationContext); + const dimensions = useWindowDimensions(); + + const updateState = (value) => { + setState((s) => ({ ...s, ...value })); + } + + useEffect(() => { + const frameRatio = state.frameWidth / state.frameHeight; + if (frameRatio > state.videoRatio) { + //height constrained + const height = 0.9 * state.frameHeight; + const width = height * state.videoRatio; + updateState({ width, height }); + } + else { + //width constrained + const width = 0.9 * state.frameWidth; + const height = width / state.videoRatio; + updateState({ width, height }); + } + }, [state.frameWidth, state.frameHeight, state.videoRatio]); + + useEffect(() => { + updateState({ frameWidth: dimensions.width, frameHeight: dimensions.height }); + }, [dimensions]); + + useEffect(() => { + const url = conversation.actions.getTopicAssetUrl(topicId, asset.hd); + updateState({ url }); + }, [topicId, conversation, asset]); + + const actions = { + setResolution: (width, height) => { + updateState({ display: {}, videoRatio: width / height }); + }, + loaded: () => { + updateState({ loaded: true }); + }, + play: () => { + actions.showControls(); + updateState({ playing: true }); + }, + pause: () => { + updateState({ playing: false }); + }, + showControls: () => { + clearTimeout(controls.current); + updateState({ controls: true }); + controls.current = setTimeout(() => { + updateState({ controls: false }); + }, 2000); + }, + }; + + return { state, actions }; +} + diff --git a/app/mobile/src/session/conversation/topicItem/videoThumb/VideoThumb.jsx b/app/mobile/src/session/conversation/topicItem/videoThumb/VideoThumb.jsx new file mode 100644 index 00000000..ac3223dc --- /dev/null +++ b/app/mobile/src/session/conversation/topicItem/videoThumb/VideoThumb.jsx @@ -0,0 +1,22 @@ +import { View, Image } from 'react-native'; +import { TouchableOpacity } from 'react-native-gesture-handler'; +import { useVideoThumb } from './useVideoThumb.hook'; +import { styles } from './VideoThumb.styled'; +import Colors from 'constants/Colors'; +import AntIcons from 'react-native-vector-icons/AntDesign'; + +export function VideoThumb({ topicId, asset, onAssetView }) { + const { state, actions } = useVideoThumb(topicId, asset); + + return ( + + + + + + + ); + +} + + diff --git a/app/mobile/src/session/conversation/topicItem/videoThumb/VideoThumb.styled.js b/app/mobile/src/session/conversation/topicItem/videoThumb/VideoThumb.styled.js new file mode 100644 index 00000000..86321073 --- /dev/null +++ b/app/mobile/src/session/conversation/topicItem/videoThumb/VideoThumb.styled.js @@ -0,0 +1,13 @@ +import { StyleSheet } from 'react-native'; +import { Colors } from 'constants/Colors'; + +export const styles = StyleSheet.create({ + overlay: { + marginRight: 16, + position: 'absolute', + bottom: 0, + right: 0, + padding: 4, + }, +}) + diff --git a/app/mobile/src/session/conversation/topicItem/videoThumb/useVideoThumb.hook.js b/app/mobile/src/session/conversation/topicItem/videoThumb/useVideoThumb.hook.js new file mode 100644 index 00000000..e069f859 --- /dev/null +++ b/app/mobile/src/session/conversation/topicItem/videoThumb/useVideoThumb.hook.js @@ -0,0 +1,32 @@ +import { useState, useRef, useEffect, useContext } from 'react'; +import { ConversationContext } from 'context/ConversationContext'; +import { Image } from 'react-native'; + +export function useVideoThumb(topicId, asset) { + + const [state, setState] = useState({ + ratio: 1, + url: null, + }); + + const conversation = useContext(ConversationContext); + + const updateState = (value) => { + setState((s) => ({ ...s, ...value })); + } + + useEffect(() => { + const url = conversation.actions.getTopicAssetUrl(topicId, asset.thumb); + if (url) { + Image.getSize(url, (width, height) => { + updateState({ url, ratio: width / height }); + }); + } + }, [topicId, conversation, asset]); + + const actions = { + }; + + return { state, actions }; +} + diff --git a/app/mobile/src/session/conversation/useConversation.hook.js b/app/mobile/src/session/conversation/useConversation.hook.js index e9c5a1d3..b352ce94 100644 --- a/app/mobile/src/session/conversation/useConversation.hook.js +++ b/app/mobile/src/session/conversation/useConversation.hook.js @@ -25,7 +25,22 @@ export function useConversation() { const cards = card.state.cards; cardImageUrl = card.actions.getCardImageUrl; const { logo, subject } = getChannelSubjectLogo(cardId, profileGuid, channel, cards, cardImageUrl); - updateState({ logo, subject }); + + const items = Array.from(conversation.state.topics.values()); + const sorted = items.sort((a, b) => { + const aTimestamp = a?.detail?.created; + const bTimestamp = b?.detail?.created; + if(aTimestamp === bTimestamp) { + return 0; + } + if(aTimestamp == null || aTimestamp < bTimestamp) { + return 1; + } + return -1; + }); + const filtered = sorted.filter(item => !(item.blocked === 1)); + + updateState({ logo, subject, topics: filtered }); }, [conversation.state, card.state, profile.state]);