教程:如何使用 React Native 构建 Slack 克隆(第一部分)

2025-05-28

教程:如何使用 React Native 构建 Slack 克隆(第一部分)

React Native在移动开发领域占有重要地位。随着每个新版本的发布,它的开发速度和性能也越来越出色。构建一个聊天应用程序曾经是一项艰巨的工作,但借助 React-native 和Stream Chat的强大功能,只需几分钟即可创建一个消息应用程序。

在本教程中,我们将构建一个 Slack 的克隆版本,Slack 是一个面向工作场所的消息平台。Slack 应用程序具有丰富的功能。在本教程的这一部分,我们将介绍 Slack 的以下 UI/UX 功能:

  • 频道列表导航
  • 输入框
  • 消息行
  • 反应列表
  • Giphy 卡片
  • 丰富的 URL 预览

结果将如下所示:

流聊天 - 最终 Slack 克隆 - 预览

注意:本教程的目的并非帮助您构建一个可用于生产的 Slack 应用程序克隆版本(因为它已经存在)。相反,本教程将作为一个入门指南,帮助您了解如何使用 Stream 的聊天和消息传递 API 及 SDK 提供的 UI 组件构建真实的聊天功能。

如果您在本教程中感到迷茫,以下资源将会有所帮助:

注意:如果您想向应用程序发送消息,以检查实时功能是否正常工作,请使用此CodePen

资源👇

如果您在途中遇到困难,以下几个链接可以为您提供帮助:

快速测试🥽

如果您希望快速查看应用程序的最终运行状态,请克隆以下 slack clone 的 expo 示例并在模拟器或手机上运行它:

步骤 1:设置🛠️

开发环境设置

在开始之前,请确保你已经为 react-native 设置好了开发环境。请阅读react-native 官方文档“安装依赖项”部分。

项目设置

设置好开发环境后,创建一个新的 react-native 应用程序:

Slack 使用 Lato 字体,该字体可在https://fonts.google.com/上免费获取。为了达到视觉效果,我们需要将该字体导入到我们的应用中。为此,请react-native.config.js在项目目录中创建一个名为的文件,并粘贴以下内容:

slack-clone您可以从项目存储库下载 Lato 字体文件,并从这里下载图标

或者,您可以从Google Fonts 网站下载字体。您会Download family在顶部看到一个标题按钮。

接下来在项目根目录下准备如下目录结构:

流聊天克隆 - 目录列表

请在此步骤运行以下命令:

完成这些步骤后,您的 slack-clone 应用所需的设置就完成了。现在,您应该能够使用以下命令在模拟器上启动该应用。启动后,您将看到 React Native 的欢迎屏幕。

React Native - 欢迎屏幕

步骤 2:组件

基本导航抽屉

让我们首先在应用中创建一个基本的抽屉导航。将 的内容替换App.js为以下代码:

完成此操作后,如果您检查模拟器,您应该会看到基本的类似 Slack 的抽屉导航。

Stream Slack Clone - 入门

频道列表导航🧭

现在,让我们创建一个频道列表导航,并将其添加到刚刚创建的抽屉中。对于 Slack 导航抽屉,我们将重点关注以下一些基本 UI 元素:

  • 频道分组依据
    • 未读频道
    • 频道(读取频道)
    • 直接消息 - 这是react-native 中SectionList的完美用例
  • 未读频道标签以粗体显示
  • 直接消息用户的名字旁边有一个在线指示器 - 如果他们在线则为绿色,否则为空心圆圈。

让我们创建一个名为 的文件src/components/ChannelList.js。您可以将以下代码片段的内容复制到新创建的文件中:

此外,用以下内容替换ChannelListDrawer组件App.js

如果你熟悉 React-native,这段代码应该相当直观。我们添加了一个SectionList包含三个部分的组件:未读消息、频道消息、私信消息。目前你应该在应用中看到以下内容:

流聊天克隆 - 基础

现在让我们填充SectionList一些频道。正如我在本教程前面提到的,我们将使用 Stream 的聊天基础架构。

让我们首先创建一个 Stream Chat 客户端App.js并将其作为 prop 传递给ChannelList组件。

我们还添加了一个名为 的 prop 函数changeChannel,它负责打开频道屏幕并将提供的频道 ID 传递给它。我们将使用此函数作为onPress的处理程序ChannelListItem

现在让我们在ChannelList.js文件中创建一个钩子,用于查询频道。稍后,当有新消息到达或在群组之间移动消息时,我们会实时更新它们。

如果您不熟悉 React hooks,这里有一些很棒的入门资源:

此钩子使用 Stream 客户端查询频道。它将频道分为三类,并作为状态变量返回unreadChannelsreadChannelsoneOnOneConversations

renderChannelListItem函数当前返回<Text>{channel.id}</Text>,显示频道的 ID。让我们为该项目创建一个类似 Slack 的 UI。

在名为 的单独文件中创建一个新组件src/components/ChannelListItem.js

该组件将根据群组频道、一对一对话或未读频道来确保不同的样式。它还会检查是否包含用户提及。

现在让我们在组件ChannelListItem中使用我们的组件ChannelListSectionList

import React, {useState, useEffect} from 'react';
import {
View,
Text,
SafeAreaView,
TextInput,
StyleSheet,
SectionList,
} from 'react-native';
import {ChannelListItem} from './ChannelListItem';
export const ChannelList = ({client, changeChannel}) => {
const {
activeChannelId,
setActiveChannelId,
unreadChannels,
readChannels,
oneOnOneConversations,
} = useWatchedChannels(client, changeChannel);
const renderChannelRow = (channel, isUnread) => {
const isOneOnOneConversation =
Object.keys(channel.state.members).length === 2;
return (
<ChannelListItem
activeChannelId={activeChannelId}
setActiveChannelId={setActiveChannelId}
changeChannel={changeChannel}
isOneOnOneConversation={isOneOnOneConversation}
isUnread={isUnread}
channel={channel}
client={client}
key={channel.id}
currentUserId={client.user.id}
/>
);
};
return (
<SafeAreaView>
<View style={styles.container}>
<View style={styles.headerContainer}>
<TextInput
style={styles.inputSearchBox}
placeholderTextColor="grey"
placeholder="Jump to"
/>
</View>
<SectionList
style={styles.sectionList}
sections={[
{
title: 'Unread',
id: 'unread',
data: unreadChannels || [],
},
{
title: 'Channels',
data: readChannels || [],
},
{
title: 'Direct Messages',
data: oneOnOneConversations || [],
},
]}
keyExtractor={(item, index) => item.id + index}
renderItem={({item, section}) => {
return renderChannelRow(item, section.id === 'unread');
}}
renderSectionHeader={({section: {title}}) => (
<View style={styles.groupTitleContainer}>
<Text style={styles.groupTitle}>{title}</Text>
</View>
)}
/>
</View>
</SafeAreaView>
);
};

正如你在这里注意到的,我提供了isUnread: true未读部分的数据。这样,我就可以告诉renderChannelRow函数当前要渲染的通道是否为未读通道。

renderChannelRow这不是必需的,因为您可以快速获取正在使用的频道的未读计数,channel.unreadCount()以判断该消息是否已读。但这只是为了避免额外调用channel.countUnread(),这本质上是循环遍历消息。

如果您重新加载应用程序,您应该会看到频道列表中填充了几个频道,如下面的屏幕截图所示:

Stream Chat Slack Clone - 通知指示器

到目前为止,ChannelList一切正常,但你会注意到它不是实时的。如果其他用户在某个频道发送了消息,它不会反映在你的 上。为此,ChannelList我们需要在钩子中实现事件处理程序。useWatchedChannels

您可以在此处找到有关 Stream 事件的详细文档

出于教程目的,我们将处理两个事件,但您可以根据需要尝试任意数量的事件:

  1. message.new- 此事件告诉我们某个频道上有一条新消息(频道数据包含在事件对象中)。在本例中,我们希望将频道从readChannelsoneOnOneConversations移动到unreadChannels
  2. message.read- 此事件告诉我们某个通道(事件对象中可用的数据)被标记为已读。在本例中,我们希望将通道从 移动unreadChannelsreadChannelsoneOnOneConversations

将钩子代码替换useWatchedChannels为以下更新的代码:

const useWatchedChannels = (client, changeChannel) => {
const [activeChannelId, setActiveChannelId] = useState(null);
const [unreadChannels, setUnreadChannels] = useState([]);
const [readChannels, setReadChannels] = useState([]);
const [oneOnOneConversations, setOneOnOneConversations] = useState([]);
const [hasMoreChannels, setHasMoreChannels] = useState(true);
const filters = {
type: 'messaging',
example: 'slack-demo',
members: {
$in: [client.user.id],
},
};
const sort = {has_unread: -1, cid: -1};
const options = {limit: 30, state: true};
useEffect(() => {
if (!hasMoreChannels) {
return;
}
let offset = 0;
const _unreadChannels = [];
const _readChannels = [];
const _oneOnOneConversations = [];
/**
* fetchChannels simply gets the channels from queryChannels endpoint
* and sorts them by following 3 categories:
*
* - Unread channels
* - Channels (read channels)
* - Direct conversations/messages
*/
async function fetchChannels() {
const channels = await client.queryChannels(filters, sort, {
...options,
offset,
});
offset = offset + channels.length;
channels.forEach((c) => {
if (c.countUnread() > 0) {
_unreadChannels.push(c);
} else if (Object.keys(c.state.members).length === 2) {
_oneOnOneConversations.push(c);
} else {
_readChannels.push(c);
}
});
setUnreadChannels([..._unreadChannels]);
setReadChannels([..._readChannels]);
setOneOnOneConversations([..._oneOnOneConversations]);
if (channels.length === options.limit) {
fetchChannels();
} else {
setHasMoreChannels(false);
setActiveChannelId(_readChannels[0].id);
changeChannel(_readChannels[0].id);
}
}
fetchChannels();
}, [client]);
useEffect(() => {
function handleEvents(e) {
if (e.type === 'message.new') {
const cid = e.cid;
// Check if the channel (which received new message) exists in group channels.
const channelReadIndex = readChannels.findIndex(
(channel) => channel.cid === cid,
);
if (channelReadIndex >= 0) {
// If yes, then remove it from reacChannels list and add it to unreadChannels list
const channel = readChannels[channelReadIndex];
readChannels.splice(channelReadIndex, 1);
setReadChannels([...readChannels]);
setUnreadChannels([channel, ...unreadChannels]);
}
// Check if the channel (which received new message) exists in oneOnOneConversations list.
const oneOnOneConversationIndex = oneOnOneConversations.findIndex(
(channel) => channel.cid === cid,
);
if (oneOnOneConversationIndex >= 0) {
// If yes, then remove it from oneOnOneConversations list and add it to unreadChannels list
const channel = oneOnOneConversations[oneOnOneConversationIndex];
oneOnOneConversations.splice(oneOnOneConversationIndex, 1);
setOneOnOneConversations([...oneOnOneConversations]);
setUnreadChannels([channel, ...unreadChannels]);
}
// Check if the channel (which received new message) already exists in unreadChannels.
const channelUnreadIndex = unreadChannels.findIndex(
(channel) => channel.cid === cid,
);
if (channelUnreadIndex >= 0) {
const channel = unreadChannels[channelUnreadIndex];
unreadChannels.splice(channelUnreadIndex, 1);
setReadChannels([...readChannels]);
setUnreadChannels([channel, ...unreadChannels]);
}
}
if (e.type === 'message.read') {
if (e.user.id !== client.user.id) {
return;
}
const cid = e.cid;
// get channel index
const channelIndex = unreadChannels.findIndex(
(channel) => channel.cid === cid,
);
if (channelIndex < 0) {
return;
}
// get channel from channels
const channel = unreadChannels[channelIndex];
unreadChannels.splice(channelIndex, 1);
setUnreadChannels([...unreadChannels]);
if (Object.keys(channel.state.members).length === 2) {
setOneOnOneConversations([channel, ...oneOnOneConversations]);
} else {
setReadChannels([channel, ...readChannels]);
}
}
}
client.on(handleEvents);
return () => {
client.off(handleEvents);
};
}, [client, readChannels, unreadChannels, oneOnOneConversations]);
return {
activeChannelId,
setActiveChannelId,
unreadChannels,
setUnreadChannels,
readChannels,
setReadChannels,
oneOnOneConversations,
setOneOnOneConversations,
};
};

我们在这里添加了另一个useEffect钩子,它将事件监听器添加到我们的流客户端,并在组件卸载时负责移除该监听器。这handleEvent是一个事件处理程序,它会根据接收到的事件采取相应的操作。

作为练习,您可以尝试添加其他事件的处理程序,例如user.presence.changedchannel.updatedchannel.deleted

现在尝试从这个CodePen向某个频道发送一条消息(使用用户Tommaso),你应该会看到带有新消息的频道移至未读部分。

现在我们需要处理的最后一件事是的onclick处理程序ChannelListItem。当选择一个项目时,我们需要更新中的通道ChannelScreen

我们的组件到此就完成了ChannelList。如果你向此列表中的某个频道发送一条消息,你将看到事件处理程序执行相应的操作,并更新列表 UI。

频道屏幕📱

让我们首先构建如下所示的频道标题:

流聊天 Slack Clone - 频道名称

为标题创建一个新文件 - src/components/ChannelHeader.js

import React from 'react';
import {TouchableOpacity, View, Text, Image, StyleSheet} from 'react-native';
import iconSearch from '../images/icon-search.png';
import iconThreeDots from '../images/icon-3-dots.png';
export const ChannelHeader = ({navigation, channel, client}) => {
let channelTitle = '#channel_name';
// For normal group channel/conversation, its channel name as display title.
if (channel && channel.data && channel.data.name) {
channelTitle = '# ' + channel.data.name.toLowerCase().replace(' ', '_');
}
const memberIds =
channel && channel.state ? Object.keys(channel.state.members) : [];
// Check if its oneOneOneConversation.
if (channel && memberIds.length === 2) {
// If yes, then use name of other user in conversation as channel display title.
const otherUserId =
memberIds[0] === client.user.id ? memberIds[1] : memberIds[0];
channelTitle = channel.state.members[otherUserId].user.name;
}
return (
<View style={styles.container}>
<View style={styles.leftContent}>
<TouchableOpacity
onPress={() => {
navigation.openDrawer();
}}>
<Text style={styles.hamburgerIcon}></Text>
</TouchableOpacity>
<Text style={styles.channelTitle}>{channelTitle}</Text>
</View>
{/* Message search and menu popup are not functional here. We will cover them in some future tutorial. */}
<View style={styles.rightContent}>
<TouchableOpacity style={styles.searchIconContainer}>
<Image source={iconSearch} style={styles.searchIcon} />
</TouchableOpacity>
<TouchableOpacity style={styles.menuIconContainer}>
<Image source={iconThreeDots} style={styles.menuIcon} />
</TouchableOpacity>
</View>
</View>
);
};
export const styles = StyleSheet.create({
container: {
padding: 15,
flexDirection: 'row',
backgroundColor: 'white',
justifyContent: 'space-between',
borderBottomWidth: 0.5,
borderBottomColor: 'grey',
},
leftContent: {
flexDirection: 'row',
},
hamburgerIcon: {
fontSize: 27,
},
channelTitle: {
color: 'black',
marginLeft: 10,
fontWeight: '900',
fontSize: 17,
fontFamily: 'Lato-Regular',
},
rightContent: {
flexDirection: 'row',
marginRight: 10,
},
searchIconContainer: {marginRight: 15, alignSelf: 'center'},
searchIcon: {
height: 18,
width: 18,
},
menuIcon: {
height: 18,
width: 18,
},
menuIconContainer: {alignSelf: 'center'},
});

为此,我们在屏幕左侧添加了一个汉堡图标,单击该图标将打开导航抽屉。

我们尚未将其放入ChannelHeader我们的组件中ChannelScreen

使用以下内容更新ChannelScreen组件:App.js

import {ChannelHeader} from './src/components/ChannelHeader';
import React, {useEffect, useState} from 'react';
function ChannelScreen({navigation, route}) {
const [channel, setChannel] = useState(null);
useEffect(() => {
if (!channel) {
navigation.openDrawer();
}
const channelId = route.params ? route.params.channelId : null;
const _channel = chatClient.channel('messaging', channelId);
setChannel(_channel);
}, [route.params]);
return (
<SafeAreaView style={styles.channelScreenSaveAreaView}>
<View style={styles.channelScreenContainer}>
<ChannelHeader
navigation={navigation}
channel={channel}
client={chatClient}
/>
</View>
</SafeAreaView>
);
}
view raw App-4.js hosted with ❤ by GitHub

如果您重新加载应用程序,您应该会看到一个空白的频道屏幕,顶部有标题:

流聊天克隆 - 空频道

现在让我们继续将MessageListMessageInput组件添加到我们的ChannelScreen

这两个组件由 Stream 作为react-native-sdk的一部分提供

ChannelScreen请使用以下内容更新组件:

import {
Chat,
MessageList,
MessageInput,
Channel,
} from 'stream-chat-react-native';
function ChannelScreen({navigation, route}) {
const [channel, setChannel] = useState(null);
useEffect(() => {
if (!channel) {
navigation.openDrawer();
}
const channelId = route.params ? route.params.channelId : null;
const _channel = chatClient.channel('messaging', channelId);
setChannel(_channel);
}, [route.params]);
return (
<SafeAreaView style={styles.channelScreenSaveAreaView}>
<View style={styles.channelScreenContainer}>
<ChannelHeader
navigation={navigation}
channel={channel}
client={chatClient}
/>
<View style={styles.chatContainer}>
<Chat client={chatClient}>
<Channel channel={channel}>
<MessageList />
<MessageInput />
</Channel>
</Chat>
</View>
</View>
</SafeAreaView>
);
}
view raw App-5.js hosted with ❤ by GitHub

完成此更改后,您将在我们的频道屏幕底部看到消息和输入框。

流聊天 Slack Clone - 反应

但它看起来不太像 Slack 消息。所以现在我们需要做一些修改,让它看起来像 Slack。以下是 Slack UI 中与应用当前 UI 区别开来的部分。

流聊天 Slack Clone - 消息

  1. 用户名显示在消息顶部
  2. 头像(消息旁边的圆形用户个人资料图片)应该是正方形
  3. 回应应该在消息底部
  4. 每个反应旁边应显示反应计数
  5. URL 预览应该有一个粗的左灰色边框,并且其内容对齐偏移量
  6. 所有消息都应显示在屏幕左侧
  7. GIF 在 Slack 频道中的显示方式有所不同
  8. 消息之间的日期分隔符应显示在灰线上方
  9. 发送和附加按钮应位于输入框下方。

我们将逐一解决这些问题。Stream 的 React-native SDK 使用MessageSimple作为默认消息组件。但您也可以使用自定义 UI 组件作为消息组件——参考此处

首先,让我们添加一些基本的自定义主题样式。让我们创建一个自定义消息组件(名为MessageSlack),该组件内部使用了 MessageSimple 并进行了修改。该MessageSimple组件提供了丰富的自定义功能。我们将为该组件支持的以下 props 创建自定义组件MessageSimple

注意:请查看食谱以获取更多示例)

  • 消息头像
  • MessageFooter(包含反应)
  • MessageHeader(包含发件人的用户名)
  • 消息文本
  • UrlPreview(用于显示丰富的 URL 预览)
  • Giphy(用于显示 Giphy 卡片)

让我们创建每个组件:

src/components/MessageSlack.js

import React from 'react';
import {MessageSimple} from 'stream-chat-react-native';
import {MessageFooter} from './MessageFooter';
import {MessageText} from './MessageText';
import {MessageAvatar} from './MessageAvatar';
import {MessageHeader} from './MessageHeader';
import {UrlPreview} from './UrlPreview';
import {Giphy} from './Giphy';
export const MessageSlack = props => {
if (props.message.deleted_at) {
return null;
}
return (
<MessageSimple
{...props}
forceAlign="left"
ReactionList={null}
MessageAvatar={MessageAvatar}
MessageHeader={MessageHeader}
MessageFooter={MessageFooter}
MessageText={MessageText}
UrlPreview={UrlPreview}
Giphy={Giphy}
/>
);
};

src/components/MessageFooter.js

import React from 'react';
import {ReactionPickerWrapper} from 'stream-chat-react-native';
import {StyleSheet, Image, View, TouchableOpacity, Text} from 'react-native';
import iconEmoticon from '../images/icon-emoticon.png';
export const MessageFooter = props => {
return (
<View style={styles.reactionListContainer}>
{props.message.latest_reactions &&
props.message.latest_reactions.length > 0 &&
renderReactions(
props.message.latest_reactions,
props.supportedReactions,
props.message.reaction_counts,
props.handleReaction,
)}
<ReactionPickerWrapper
{...props}
offset={{
left: -70,
top: 10,
}}>
{props.message.latest_reactions &&
props.message.latest_reactions.length > 0 && (
<View style={styles.reactionPickerContainer}>
<Image source={iconEmoticon} style={styles.reactionPickerIcon} />
</View>
)}
</ReactionPickerWrapper>
</View>
);
};
export const renderReactions = (
reactions,
supportedReactions,
reactionCounts,
handleReaction,
) => {
const reactionsByType = {};
reactions &&
reactions.forEach(item => {
if (reactions[item.type] === undefined) {
return (reactionsByType[item.type] = [item]);
} else {
return (reactionsByType[item.type] = [
...reactionsByType[item.type],
item,
]);
}
});
const emojiDataByType = {};
supportedReactions.forEach(e => (emojiDataByType[e.id] = e));
const reactionTypes = supportedReactions.map(e => e.id);
return Object.keys(reactionsByType).map((type, index) =>
reactionTypes.indexOf(type) > -1 ? (
<ReactionItem
key={index}
type={type}
handleReaction={handleReaction}
reactionCounts={reactionCounts}
emojiDataByType={emojiDataByType}
/>
) : null,
);
};
const ReactionItem = ({
type,
handleReaction,
reactionCounts,
emojiDataByType,
}) => {
return (
<TouchableOpacity
onPress={() => {
handleReaction(type);
}}
key={type}
style={styles.reactionItemContainer}>
<Text style={styles.reactionItem}>
{emojiDataByType[type].icon} {reactionCounts[type]}
</Text>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
reactionListContainer: {
flexDirection: 'row',
alignSelf: 'flex-start',
marginTop: 5,
marginBottom: 10,
marginLeft: 10,
},
reactionItemContainer: {
borderColor: '#0064c2',
borderWidth: 1,
padding: 4,
paddingLeft: 5,
paddingRight: 5,
borderRadius: 10,
backgroundColor: '#d6ebff',
marginRight: 5,
},
reactionItem: {
color: '#0064c2',
fontSize: 14,
},
reactionPickerContainer: {
padding: 4,
borderRadius: 10,
backgroundColor: '#F0F0F0',
},
reactionPickerIcon: {
width: 19,
height: 19,
},
});

src/components/MessageHeader.js

import React from 'react';
import {View, Text, StyleSheet} from 'react-native';
import Moment from 'moment';
export const MessageHeader = props => {
return (
<View style={styles.column}>
{props.message.attachments.length > 0 && (
<View style={styles.header}>
<MessageUserBar {...props} />
</View>
)}
</View>
);
};
export const MessageUserBar = ({groupStyles, message}) => {
if (groupStyles[0] === 'single' || groupStyles[0] === 'top') {
return (
<View style={styles.userBar}>
<Text style={styles.messageUserName}>{message.user.name}</Text>
<Text style={styles.messageDate}>
{Moment(message.created_at).format('hh:ss A')}
</Text>
</View>
);
}
return null;
};
const styles = StyleSheet.create({
column: {
flexDirection: 'column',
},
header: {
paddingLeft: 8,
},
userBar: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 5,
},
messageUserName: {
fontWeight: '900',
color: 'black',
fontSize: 15,
fontFamily: 'Lato-Bold',
},
messageDate: {
color: 'grey',
marginLeft: 6,
fontSize: 10,
},
});

src/components/MessageText.js

import React from 'react';
import {MessageUserBar} from './MessageHeader';
export const MessageText = props => {
return (
<React.Fragment>
{props.message.attachments.length === 0 && <MessageUserBar {...props} />}
{props.renderText(props.message, props.theme.message.content.markdown)}
</React.Fragment>
);
};

src/components/MessageAvatar.js

import React from 'react';
import {MessageAvatar as StreamMessageAvatar} from 'stream-chat-react-native';
export const MessageAvatar = props => {
return (
<StreamMessageAvatar
{...props}
showAvatar={
props.groupStyles[0] === 'single' || props.groupStyles[0] === 'top'
? true
: false
}
/>
);
};

src/components/UrlPreview.js

import React from 'react';
import {View, Text, TouchableOpacity, Image, StyleSheet} from 'react-native';
export const UrlPreview = props => {
const getDomain = url => {
let domain = url && url.replace('https://', '').replace('http://', '');
if (!domain) {
return url;
}
const indexOfSlash = domain.indexOf('/');
if (indexOfSlash === -1) {
return domain;
}
return domain.slice(0, indexOfSlash);
};
return (
<TouchableOpacity style={styles.container}>
<View style={styles.detailsContainer}>
<Text style={styles.titleUrl}>{getDomain(props.title_link || props.og_scrape_url)}</Text>
<Text style={styles.title}>{props.title}</Text>
<Text style={styles.description}>{props.text}</Text>
</View>
<View style={styles.thumbnailContainer}>
<Image
source={{
url: props.image_url || props.thumb_url,
}}
style={styles.thumbnail}
/>
</View>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
borderLeftWidth: 5,
borderLeftColor: '#E4E4E4',
paddingLeft: 10,
marginLeft: 10,
flexDirection: 'row',
},
detailsContainer: {
flexDirection: 'column',
flex: 6,
},
thumbnailContainer: {
flex: 1,
},
thumbnail: {
height: 40,
width: 40,
},
titleUrl: {
fontFamily: 'Lato-Regular',
fontWeight: 'bold',
padding: 2,
},
title: {
fontFamily: 'Lato-Regular',
fontWeight: 'bold',
color: '#1E75BE',
padding: 2,
},
description: {
fontFamily: 'Lato-Regular',
padding: 2,
},
});
view raw UrlPreview-1.js hosted with ❤ by GitHub

src/components/Giphy.js

import React from 'react';
import {Text, TouchableOpacity, Image, StyleSheet} from 'react-native';
export const Giphy = props => {
return (
<TouchableOpacity style={styles.container}>
<Text style={styles.title}>{props.title}</Text>
<Text style={styles.description}>Posted using Giphy.com</Text>
<Image
source={{
url: props.image_url || props.thumb_url,
}}
style={styles.thumbnail}
/>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
borderLeftWidth: 5,
borderLeftColor: '#E4E4E4',
paddingLeft: 10,
marginLeft: 10,
marginBottom: 10,
flexDirection: 'column',
},
thumbnail: {
height: 150,
width: 250,
borderRadius: 10,
},
title: {
fontFamily: 'Lato-Regular',
fontWeight: 'bold',
color: '#1E75BE',
padding: 2,
},
description: {
fontFamily: 'Lato-Regular',
padding: 2,
fontSize: 13,
fontWeight: '300',
},
});
view raw Giphy-1.js hosted with ❤ by GitHub

我们还需要一个自定义DateSeparator组件。Stream 默认使用的组件会将日期显示在中间的空格/线中。然而,在 Slack UI 中,日期显示在顶部,并以灰色的空格/线显示。

src/components/DateSeparator.js

import React from 'react';
import {View, Text, StyleSheet} from 'react-native';
import moment from 'moment';
export const DateSeparator = ({message}) => {
return (
<View style={styles.container}>
<Text style={styles.date}>{moment(message.date).calendar()}</Text>
<View style={styles.line} />
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'column',
marginBottom: 10,
marginTop: 10,
},
date: {
fontWeight: 'bold',
paddingBottom: 5,
fontSize: 12,
},
line: {
flex: 1,
height: 0.5,
backgroundColor: '#E8E8E8',
},
});

现在,在此之后,您需要做的就是将MessageSlackDateSeparator传递MessageListApp.js.

import {DateSeparator} from './src/components/DateSeparator';
import {MessageSlack} from './src/components/MessageSlack';
function ChannelScreen({navigation, route}) {
const [channel, setChannel] = useState(null);
useEffect(() => {
if (!channel) {
navigation.openDrawer();
}
const channelId = route.params ? route.params.channelId : null;
const _channel = chatClient.channel('messaging', channelId);
setChannel(_channel);
}, [route.params]);
return (
<SafeAreaView style={styles.channelScreenSaveAreaView}>
<View style={styles.channelScreenContainer}>
<ChannelHeader
navigation={navigation}
channel={channel}
client={chatClient}
/>
<View style={styles.chatContainer}>
<Chat client={chatClient}>
<Channel channel={channel}>
<MessageList
Message={MessageSlack}
DateSeparator={DateSeparator}
/>
<MessageInput />
</Channel>
</Chat>
</View>
</View>
</SafeAreaView>
);
}
view raw App-6.js hosted with ❤ by GitHub

如果您刷新应用程序,您将看到 UI 现在与 Slack UI 有更好的一致性。

我们还需要添加一些最后的修饰,比如方形头像。头像应该与消息顶部对齐,并且消息不应该有边框,所以我们还需要做一些小的对齐调整。

我们将通过主题化聊天组件来解决这些问题。请阅读 Stream 的 react-native聊天教程中的自定义样式部分。

创建一个名为的文件src/stream-chat-theme.js

export default {
'messageList.dateSeparator.date': 'color: black;',
'messageInput.container':
'border-top-color: #979A9A; border-top-width: 0.4; background-color: white; margin: 0; border-radius: 0;',
'messageList.dateSeparator.container': 'margin-top: 10; margin-bottom: 5;',
'message.avatarWrapper.spacer': 'height: 0;',
'messageInput.sendButtonIcon': 'height: 20px; width: 20px;',
'messageInput.attachButtonIcon': 'height: 20px; width: 20px;',
'messageInput.inputBox': 'font-size: 15;',
'message.content.container':
'flex: 1; align-items: stretch; max-width: 320px; padding-top: 0; border-radius: 0;',
'message.content.textContainer':
'align-self: stretch; padding-top: 0;margin-top: 0;border-color: white;width: 100%',
'message.container': 'margin-bottom: 0; margin-top: 0',
'message.avatarWrapper.container': 'align-self: flex-start',
'avatar.image': 'border-radius: 5;',
'message.card.container':
'border-top-left-radius: 8;border-top-right-radius: 8;border-bottom-left-radius: 8; border-bottom-right-radius: 8',
'message.gallery.single':
'border-top-left-radius: 8;border-top-right-radius: 8;border-bottom-left-radius: 8; border-bottom-right-radius: 8; margin-left: 5; width: 95%',
'message.gallery.galleryContainer':
'border-top-left-radius: 8;border-top-right-radius: 8;border-bottom-left-radius: 8; border-bottom-right-radius: 8; margin-left: 5; width: 95%',
'message.replies.messageRepliesText': 'color: #0064c2',
'message.content.markdown': {
text: {
fontSize: 16,
fontFamily: 'Lato-Regular',
}
},
};

现在将此主题传递给App.js 中Chat的组件ChannelScreen,如下所示:

就这样!你应该会在屏幕上看到类似 Slack 的漂亮消息了。😺

流聊天 Slack 克隆

输入框👨‍💻

现在让我们转到底部的输入框。该MessageInput组件(来自 Stream)接受Input一个自定义 UI 组件属性,用于显示在输入框中。让我们在 中创建这个自定义组件src/components/InputBox.js

import React from 'react';
import {TouchableOpacity, View, Text, StyleSheet} from 'react-native';
import {
AutoCompleteInput,
AttachButton,
SendButton,
} from 'stream-chat-react-native';
export const InputBox = props => {
return (
<View style={styles.container}>
<AutoCompleteInput {...props} />
<View style={styles.actionsContainer}>
<View style={styles.row}>
<TouchableOpacity
onPress={() => {
props.appendText('@');
}}>
<Text style={styles.textActionLabel}>@</Text>
</TouchableOpacity>
{/* Text editor is not functional yet. We will cover it in some future tutorials */}
<TouchableOpacity style={styles.textEditorContainer}>
<Text style={styles.textActionLabel}>Aa</Text>
</TouchableOpacity>
</View>
<View style={styles.row}>
<AttachButton {...props} />
<SendButton {...props} />
</View>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'column',
flex: 1,
height: 60,
},
actionsContainer: {flexDirection: 'row', justifyContent: 'space-between'},
row: {flexDirection: 'row'},
textActionLabel: {
color: '#787878',
fontSize: 18,
},
textEditorContainer: {
marginLeft: 10,
},
});
view raw InputBox-1.js hosted with ❤ by GitHub

我们在 InputBox 中使用的以下组件由 Stream 的 react-native SDK 提供,它为我们处理了很多事情:

  • AutoCompleteInput- 负责所有输入框功能,例如提及、发送消息、维护启用/禁用状态等。
  • SendButton
  • AttachButton

我们所做的只是对 的内部组件进行改组MessageInput

这里需要注意的是,必须将整个 prop 对象传递给AutoCompleteInputSendButtonAttachButton。因此, 中的所有处理程序MessageInput都可以被这些组件访问。

现在将此InputBox组件传递给MessageInput组件ChannelScreenApp.js

该组件的最终版本ChannelScreen如下:

function ChannelScreen({navigation, route}) {
const [channel, setChannel] = useState(null);
useEffect(() => {
if (!channel) {
navigation.openDrawer();
}
const channelId = route.params ? route.params.channelId : null;
const _channel = chatClient.channel('messaging', channelId);
setChannel(_channel);
}, [route.params]);
return (
<SafeAreaView style={styles.channelScreenSaveAreaView}>
<View style={styles.channelScreenContainer}>
<ChannelHeader
navigation={navigation}
channel={channel}
client={chatClient}
/>
<View style={styles.chatContainer}>
<Chat client={chatClient} style={streamChatTheme}>
<Channel channel={channel}>
<MessageList
Message={MessageSlack}
DateSeparator={DateSeparator}
/>
<MessageInput
Input={InputBox}
additionalTextInputProps={{
placeholderTextColor: '#979A9A',
placeholder:
channel && channel.data.name
? 'Message #' +
channel.data.name.toLowerCase().replace(' ', '_')
: 'Message',
}}
/>
</Channel>
</Chat>
</View>
</View>
</SafeAreaView>
);
}
view raw App-7.js hosted with ❤ by GitHub

注意:MessageInput 组件额外增加了一个 prop - additionalTextInputProps,用于修改输入框的占位符。

流聊天 - 最终 Slack 克隆

恭喜!👏

这篇教程的第一部分就到这里,关于如何使用 Stream 的 React Native 聊天组件构建一个 Slack 克隆版本。希望本教程对您有所帮助,并期待您的反馈。

在本教程的下一部分(稍后发布)中,我们将介绍其他 UI 组件及其功能,例如:

  • 线程
  • 频道搜索
  • 操作表
  • 未读消息通知
  • 还有更多!

编码愉快!

文章来源:https://dev.to/vishalnarkhede/tutorial-how-to-build-a-slack-clone-with-react-native-part-1-37kn
PREV
Docker 速查表
NEXT
不使用 create-react-app 创建 React 项目