I'm pretty new to react native and I am currently developing a chat/forum application. Recently, I have been having some trouble trying to create a direct message section for my app. I don't know how to connect my database to my frontend. Here is the issue:
I use a mongodb database that consists of two collections: messages and conversations.
Each conversation has a unique Id and each message has a chatId that corresponds to the conversation that it belongs to.
In my react native app, inside a Direct Message component, I have a flatlist that displays the different chats.
When the Direct Message component willMount() I call an async function, getChats(), that fetches the chats from the database that the current user is part of. The fetched chats are then set to the state.
Then, inside getChats(), after the chats are set to the state, I have a for loop that basically loops through the entire this.state.chats array and then calls a function getMessages(this.state.chats[i].id) which fetches all the messages that share the same chatIds as the chats ids. The fetched messages are then added to this.state.messages.
Finally, a flatlist with the props,
keyExtractor={(item)=>item._id}
data ={this.state.chats}
renderItem={({item})=>this._renderChats(item)}
extraData = {this.state}
,renders the chats.
I want to be able to show the latest messages content and sender in the chat View, however, an error saying that the messages content is undefined.
I think this may be due to the fact that messages aren't set to the state before the chats are rendered, but I'm not sure.
How would you solve this? Would you change the frontend or the backend? Would you change both? Should I share my code to make it easier to understand the problem?
Thanks in advance.
Very clear explanation of the problem! In short:
How to build a chat page with conversations overview (showing last message for each conversation) - using React Native, MongoDB, NodeJS and Express
Some notes:
use consistent naming, ie. rename chatId to conversationId and chats to conversations
try to minimize the internet requests - because they're resource intensive and slow
right now, your algorithm makes conversations.count+1 requests each time the page is opened, could be just 1
for the first page, you only need the last message in each conversation
load the rest of the messages for a conversation when its page is opened
thereby, you don't need the extraData field
(though caching, see additional notes)
eg. using GraphQL query('currentUser.conversations { text, messages(limit 1), ... }')
eg. using rest + mongoose:
// controller
// see https://stackoverflow.com/questions/32207457/node-js-mongoose-populate-limit
// populate the first item in conversation.messages
const conversations = ()=> db.Conversations
.find(...)
.populate({ path: 'messages', options: { limit: 1, sort: { created: -1} }}))
.exec(...)
// router
router.get('/conversations', ({user}, res)=>
getConversations({user})
.then(data=> res.send({data}))
.catch(error=> res.send({error}))
)
(this assumes messages are a virtual property on conversations)
// schema
const messageSchema = new Schema({
text: String, ...
conversationId: {type: Schema.ObjectId, ref: 'conversation'}
})
const conversationSchema = new Schema({
participants: [{type: Schema.ObjectId, ref: 'user'}]
})
// add a "virtual" field to conversation called messages
// https://stackoverflow.com/questions/43882577/mongoosejs-virtual-populate
conversationSchema.virtual('messages', {
ref: 'message',
localField: '_id',
foreignField: 'conversationId'
})
// make sure the "virtual" fields are included in conversion
conversationSchema.set('toObject', { virtuals: true });
conversationSchema.set('toJSON', { virtuals: true });
assume that all data is broken; eg. the app should not crash if the message data is missing. Check that the property exists before accessing it, and convert it into the expected data type before proceeding.
Make sure the errors aren't "swallowed"; eg. make sure to always have a .catch and log the error if you have a .then, and paste the error messages to the question, if any.
Uncaught TypeError: Cannot read property 'c' of undefined, when doing a.b.c, where you know that a is an object, can be avoided by first checking; if (a.b) use(a.b.c), or with shortcircuit: a.b && use(a.b.c)
correct hypothesis, what happend was:
ConversationsPage initialized, state.conversations is Empty
willMount called -> fetchAllConversations started, takes time, async
willMount ends -> render called -> empty list rendered
fetchAllConversations' first request finishes -> setState{conversations} -> render called again -> full list rendered (in your case, it crashed because the last message field was missing)
fetchAllConversations invokes fetchMessagesForConversations, which makes many api requests, and possibly calls setState multiple times (ineffective), which in turn causes re-render
don't forget the loading state
state = {loading: false}
render () {
const {loading} = this.state
return <FlatList renderHeader={()=> loading? <ActivityIndicator .../>: null} .../>
}
instead, a simple fix would be to call setState after all messages have been loaded:
async fetchAllConversations () {
const conversations = await api.conversations()
await Promise.all(conversations.map(c=> c.messages = await api.messagesForConversationId(c._id)))
// similar to for (let i=0; i<conversations.length; i++) {
// const c = conversations[i]; c.messages = await api.messagesForConversationId(c._id)}
return conversations
}
async reload () {
this.setState({loading: true})
this.fetchAllConversations()
.then(conversations=> this.setState({conversations}))
.catch(showError)
// stop loading both on error and success
.then(()=> this.setState({loading: false}))
}
state = {loading: false, conversations: []}
willMount () { this.reload() }
though a better solution would be to replace fetchAllConversations from above, assuming the use of virual property and population mentioned above server-side:
async fetchAllConversationsIncludingLastMessage () {
const conversations = await api.conversationsWithLastMessage()
return conversations
}
this would reduce the flow to:
ConversationsPage initialized, state.conversations is Empty
willMount called -> reload started, takes time, async
willMount ends -> render called -> loading indicator rendered
reload's only request finishes -> setState{conversations} -> render called again -> full list rendered
Additional notes:
look into Docker for simplified server setup (ie. to get MongoDB + Node.js running together)
I presume you've got a middleware that does the authentication, + correct authorisation logic in the query (eg. only find the conversations/messages the authorised user should have access to)
ie. db.Conversation.find({participants: {$includes: req.user._id}}) // pseudocode
ie. in messages, first see if the conversation with that id has the user as participant
how do you handle pagination? (eg. to prevent slow data fetching and slow UI when there are many posts) (tips: use a "cursor" instead of "offset" - prevents duplication issue etc)
Use some library to cache the data locally to improve perceived and actual loading time.
Central state management (using eg. Mobx, Redux, Apollo...) solves some of that
if you're going to use REST anyhow, make an API wrapper helper + look into mobx
otherwise, check out GraphQL and Apollo or similar
Related
I have a "My Chats" screen with a "ChatList" component in my web app.
Each "ChatListItem" has the user avatar, the date of the last message, the content of the last message (which can be deleted) etc.
It is represented like this in my DB
ChatRoom doc = {
lastMessage: {
date,
content,
senderId
},
members: {id1: { username, avatar }, id2: { username, avatar }},
membersIds: [id1, id2],
read: false,
}
And stored on Firestore following this NoSQL structure:
/
chatsCollection/
chatDoc1
chatDoc2
...
This is how I am listening all the current user chats:
export function listenMyChats(limitToLast, onNext, onError) {
const currentUserId = getCurrentUser().uid;
const query = firestore
.collection("chats")
.where("membersIds", "array-contains", currentUserId)
.orderBy("lastMessage.date", "asc");
return query.limitToLast(limitToLast).onSnapshot(onNext, onError);
}
If I limit the listener results to 20, then it will be not possible to handle the changes of for example, the 80th item of my ChatList.
Imagine that the other user deletes the last message he sent, updating the chatroom doc data to:
ChatRoom doc = {
lastMessage: {
date, <---- DATE HAS NOT CHANGED!
deleted: true, <-----
senderId
},
members: {id1: { username, avatar }, id2: { username, avatar }},
membersIds: [id1, id2],
read: false,
}
As you can see, the last message's date has not changed, and I am listening the 20 first items ordered by the last message's date.
This will cause a problem. If the doc is in the 21st position of my ChatList, I will not be able to update the GUI with the respective new data, as the listener will not detect any changes.
I have thought to combine the listener with a pull-to-refresh system inside the "My Chats" screen. But, is there any other approach to handle all the items without deleting the limitToLast(20)?
The issue probably is that you are trying to model your data in a different way from how Firestore is designed.
you are treating documents which come through the listener as events. But in fact what you should do is construct a query which shows exactly what data you want to see in the UI, e.g. the most recent 20 chats which aren't deleted.
The Firestore SDK will then tell you whenever the set of documents matched by the query changes. Then you need to update the UI to match the new set of documents in the watch list. If a chat is deleted, the Firestore SDK will still let you know because it tracks documents disappearing from the query results, and documents appearing.
Another option, perhaps more similar to what you are doing now, would be to have actual events stored in a collection somewhere. A deletion event would include the timestamp at which it was deleted, so you can be confident that looking at the most recent documents will be likely to include the changes you are interested in.
For more information related to “firestore infinite scroll” you can refer to the stackoverflow case and document where a brief description about the infinite scroll has been mentioned and how you can do it in your application.
I am trying to use vue-socket.io to create a chat application. So far I've figured out how to set up the socket to create rooms and such but now I am trying to make messages show between chats and update the Vuex store to show messages as I flip back and forth between the different channels.
I am having an issue figuring out how to update my Vuex state with the message. The socket server emits a message to a room that is based on a v_code that which is included in the message.
Here is my socket on message code
message(message) {
if(this.conversation.v_code == message.v_code) { // This checks to make sure just the active view is sent the message
let userID = this.$store.getters.userID
userID = userID.cognitoUsername
if(message.userID === userID) message.fromMe = true
this.displayMessages.push(message) // This is where the active view messages are seen/rendered from
}
}
The data structure of state.messages
{
{
{
v_code: <token>,
messages: [] <-- array of messages for this v_code
}
}
}
So what I am trying to figure out is how I can update state for the correct messageSet (that is what I have been calling the array of messages). I just want to push the new message to the array in the messageSet but only for the correct messageSet. I have tried using the find method but have had no luck. Here is the find that I am assigning to displayMessages when changing channels/rooms.
this.messages.find((messageSet) => messageSet.v_code === message.v_code)
How can I do what I want? I assume some version of the find method but not sure the exact way.
I have figured it out, I changed my socket on message to remove the display messages update and instead now use a Vuex action and mutation. I was close with the find part and just needed to change it so it pushed to the state. I was surprised to see it push it directly to the state using a find method.
The result was this line was needed in the mutation
state.messages.find((messageSet) => messageSet.v_code === message.v_code).messages.push(message)
I have updated my Firebase Realtime Database access rules, and have noticed some clients now tries to access paths they do not have access to. This is ok - but my problem is that my code stops after being unable to read a restricted node.
I see below error in my console, and then loading of subsequent data stops:
permission_denied at /notes/no-access-node
I begin by collecting access nodes from /access_notes/uid and continue to read all data from /notes/noteId.
My code for collecting notes from the database below:
//*** SUBSCRIPTION */
database.ref(`access_notes/${uid}`).on('value', (myNotAccessSnaps) => {
let subscrPromises = []
let collectedNots = {}
// Collect all categories we have access to
myNotAccessSnaps.forEach((accessSnap) => {
const noteId = accessSnap.key
subscrPromises.push(
database.ref(`notes/${noteId}`)
.once('value', (notSnap)=>{
const notData = notSnap.val()
const note = { id: notSnap.key, ...notData}
collectedNotes[note.id] = note
},
(error) => {
console.warn('Note does not exist or no access', error)
})
)
})
Promise.all(subscrPromises)
.then(() => {
const notesArray = Object.values(collectedNotes)
...
})
.catch((error) => { console.error(error); return Promise.resolve(true) })
I do not want the client to halt on permission_denied!
Is there a way to see if the user has access to a node /notes/no_access_note without raising an error?
Kind regards /K
I do not want the client to halt on permission_denied!
You're using Promise.all, which MDN documents as:
Promise.all() will reject immediately upon any of the input promises rejecting.
You may want to look at Promise.allSettled(), which MDN documents as:
[Promise.allSettled()] is typically used when you have multiple asynchronous tasks that are not dependent on one another to complete successfully, or you'd always like to know the result of each promise.
Is there a way to see if the user has access to a node /notes/no_access_note without raising an error?
As far as I know the SDK always logs data access permissions errors and this cannot be suppressed.
Trying to access data that the user doesn't have access to is considered a programming error in Firebase. In normal operation you code should ensure that it never encounters such an error.
This means that your data access should follow the happy path of accessing data it knows it has access to. So you store the list of the notes the user has access to, and then from that list access each individual note.
So in your situation I'd recommend finding out why you're trying to read a note the user doesn't have access to, instead of trying to hide the message from the console.
I am building a Slack app using the JavaScript Bolt framework. The concept of the app is just listening to specific message keywords in channels and then forwarding those messages to the users of the app.
What I am trying to achieve is including a permalink in the forwarded message. I am trying to use the chat.getPermalink method to get the url and then include that in my chat.postMessage method. I am trying to leverage Bolt's 'Context' in order to pass the property in chat.getPermalink to chat.postMessage. I am asking for help here because I cannot get the Context to work..
const app = new App({
token: process.env.SLACK_BOT_TOKEN,
signingSecret: process.env.SLACK_SIGNING_SECRET
});
let token = process.env.SLACK_BOT_TOKEN,
web = new WebClient(token);
let jira_text = "jira";
let rdu_qa = '#rdu_qa';
//Get permalink
async function PermaLinks({payload, context, next}) {
let perm = app.client.chat.getPermalink({
token: context.botToken,
channel: "C0109KMQCFQ",
message_ts: payload.ts
});
context.permalink = perm.permalink;
await next();
}
app.event('message', PermaLinks, async ({ payload, message, context}) => {
let userzArray = ["D010Q34TQL9", "UVBBD8989"];
//if channel is general and incldues the text 'Jira' or 'rdu_qa'
if (payload.channel === "C0109KMQCFQ") {
if (payload.text.includes(jira_text) || payload.text.includes(rdu_qa)) {
try {
// Call the chat.postMessage to each of the users
let oneUser = await userzArray.forEach(userId => { app.client.chat.postMessage({
token: context.botToken,
bot_id: "USLACKBOT",
channel: userId,
blocks: [
{
type: "section",
text: {
text: payload.text,
type: "mrkdwn"
},
fields: [
{
type: "mrkdwn",
text: `posted by <#${message.user}>`
},
{
type:"mrkdwn",
text: "in General channel" //channel.name//getChannelNameGeneral
},
{
type:"mrkdwn",
text: context.permalink // Permalink should be right here
}
]
},
{
"type": "divider"
},
] // End of block of Jira notification stuff
});
});
// console.log(result);
} catch (error) {
console.error(error);
}
} // If text sent to General channel includes keyword 'Jira' or 'rdu_qa'
} //end of if message was posted in General channel
There are a couple problems I can see in the example code, but I think the main issue regarding the context is that you're storing a Promise as context.permalink, not the actual result of the method call. In order to store the result, you should use the await keyword before calling the method (app.client.chat.getPermalink(...)).
I've revised the code you shared here, and I'll explain the modifications below.
const { App } = require('#slack/bolt');
const token = process.env.SLACK_BOT_TOKEN
const app = new App({
signingSecret: process.env.SLACK_SIGNING_SECRET,
token,
});
// Users who should be notified when certain messages are heard
let userzArray = ["D010Q34TQL9", "UVBBD8989"];
// Conversation IDs corresponding to the users in the array above. This variable will be set automatically when the app starts.
let conversationsToNotify;
// Match messages that include the text 'jira' or '#rdu_qa'
app.message(/jira|#rdu_qa/, async ({ message, client }) => {
// Match the messages that are in the specified channel
if (message.channel === 'C0109KMQCFQ') {
try {
// Get a permalink to this message
const permalinkResult = await client.chat.getPermalink({
channel: message.channel,
message_ts: message.ts,
});
// Send a message to each user containing the permalink for this message
await Promise.all(conversationsToNotify.map((conversationId) => {
return client.chat.postMessage({
channel: conversationId,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `>>> ${payload.text}`,
},
fields: [
{
type: 'mrkdwn',
text: `posted by <#${message.user}>`,
},
{
type: 'mrkdwn',
text: `in <#${message.channel}>`,
},
{
type:'mrkdwn',
text: `<Original|${permalinkResult.permalink}>`,
},
],
},
{
type: 'divider'
},
],
});
}));
} catch (error) {
console.error(error);
}
}
});
async function convertUsersToConversations(input) {
return Promise.all(input.map((id) => {
// For all IDs that seem like user IDs, convert them to a DM conversation ID
if (id.startsWith('U')) {
return app.client.conversations.open({
token,
users: id,
})
.then((result) => result.channel.id);
}
// For all IDs that don't seem to belong to a user, return them as is
return id;
}));
});
(async () => {
// Start the app
conversationsToNotify = await convertUsersToConversations(userzArray);
await app.start(process.env.PORT || 3000);
console.log('⚡️ Bolt app is running!');
})();
I've removed the initialization of a new WebClient object. In Bolt v1.6.0 and later, there is a client argument available in listeners and middleware which you can use to call Web API methods instead. The advantage of using the client argument is that you don't need to read the token from the context and pass it as an argument for each method call on your own, Bolt will find the right token for you.
Instead of using the app.event('message', ...) method to listen for message events, I've changed to using app.message(...). The latter works mostly the same, but has one more advantage: you can pass a pattern to match the text of a message as the first argument (before the listener function): app.message(pattern, ...). That helps remove some of the conditions inside the listener. Instead of using just the two string variables jira_text and #rdu_qa, I've combined them in a single regular expression that matches when either of those values is seen in the text: /jira|#rdu_qa/.
Instead of using middleware to find the permalink of a message, I've moved that code into the listener. Middleware should be used to reuse code across multiple listeners (or global middleware to reuse code across all listeners). In your example, it doesn't seem like the code to find the permalink is being reused, but if you do use this in many listeners, it should be relatively easy to extract. Another advantage is now the logic only runs after the pattern was matched, so you're not making these calls for every single message that the bot sees in all channels that it is a member of (this is much better for performance).
Use Promise.all() to collect the Promises of each call to chat.postMessage into one promise. Currently, you're using userzArray.forEach(...), which doesn't return anything. So then using await on that value will immediately resolve, and doesn't really do anything useful. What we need to do is collect each of the Promises and wait for them to all complete. This is what Promise.all() does. We just need an array of Promises to pass in, which we can get by simply changing userzArray.forEach() to userzArray.map().
There's a problem with the way you're calling chat.postMessage. You're trying to use Slackbot to send those messages, but that's not recommended because users are less likely to understand where that message is coming from. Instead, you should send this message as a DM from your bot user. In order to do that, you need a conversation ID, not a user ID, for each user you want to send this notification to. One of the items in userzArray is already a DM conversation ID (it starts with a D), but the other is not. In order to make this work consistently, I've created the conversationsToNotify array which contains the conversation IDs for each user after calling conversations.open to create a DM. Now in the code, you'll see conversationsToNotify.map() instead of userzArray.map(). Your Slack app will now need the im:write and chat:write permission scopes (don't forget to reinstall once you add scopes). Looking up the conversation IDs will slow down your app from starting up if the number of users in the array gets larger. My recommendation would be to save the conversation IDs in your code (or in a database) instead of the user IDs. This will ensure a consistently fast start up time for your app.
There's an opportunity to do even better. What happens when the first call to chat.postMessage fails? The way I've written the code above, the error would be logged to the console, but later if the second call fails, there's no way to know. That's because Promise.all() returns a promise that will reject as soon as any one of the promises rejects, and then ignores what happens afterwards. If you're using Node v12.9.0 or greater, I would recommend using Promise.allSettled() instead (which would require a few changes in your catch clause as well).
General cleanup:
Use message argument in the listener everywhere instead of payload argument. These are actually the same value when dealing with message events. payload is mostly only useful in middleware that handle several kinds of events (action, event, view, shortcut, etc) so that there's one way to refer to all of their payloads.
Move userzArray outside the listener, and make it a constant. There's no point in redeclaring it inside the listener each time it runs, and it doesn't change.
I added a function to convert from user IDs to conversation IDs (convertUsersToConversations). This function is called before the app is started to avoid a race condition where the an incoming message is handled before the app knows which channels to notify.
Formatted the text content of the message as quoted text, formatted the channel mention, and formatted the permalink. One improvement I'd also recommend is to use a context block to show the message author's name and avatar image.
I have an app that uses firebase, the whole stack pretty much, functions, database, storage, auth, messaging, the whole 9. I want to keep the client end very lightweight. So if a user comments on a post and "tags" another user, let's say using the typical "#username" style tagging, I moved all of the heavy lifting to the firebase functions. That way the client doesn't have to figure out the user ID based on the username, and do everything else. It is setup using triggers, so when the above scenario happens I write to a "table" called "create_notifications" with some data like
{
type: "comment",
post_id: postID,
from: user.getUid(),
comment_id: newCommentKey,
to: taggedUser
}
Where the taggedUser is the username, the postID is the active post, the newCommentKey is retrieved from .push() on the comments db reference, and the user.getUid() is from the firebase auth class.
Now in my firebase functions I have a "onWrite" trigger for that specific table that gets all of the relevant information and sends out a notification to the poster of the post with all the relevant details. All of that is complete, what I am trying to figure out is... how do I delete the incoming event, that way I don't need any sort of cron jobs to clear out this table. I can just grab the event, do my needed calculations and data gathering, send the message, then delete the incoming event so it never even really exists in the database except for the small amount of time it took to gather the data.
A simplified sample of the firebase functions trigger is...
exports.createNotification = functions.database.ref("/create_notifications/{notification_id}").onWrite(event => {
const from = event.data.val().from;
const toName = event.data.val().to;
const notificationType = event.data.val().type;
const post_id = event.data.val().post_id;
var comment_id, commentReference;
if(notificationType == "comment") {
comment_id = event.data.val().comment_id;
}
const toUser = admin.database().ref(`users`).orderByChild("username").equalTo(toName).once('value');
const fromUser = admin.database().ref(`/users/${from}`).once('value');
const referencePost = admin.database().ref(`posts/${post_id}`).once('value');
return Promise.all([toUser, fromUser, referencePost]).then(results => {
const toUserRef = results[0];
const fromUserRef = results[1];
const postRef = results[2];
var newNotification = {
type: notificationType,
post_id: post_id,
from: from,
sent: false,
create_on: Date.now()
}
if(notificationType == "comment") {
newNotification.comment_id = comment_id;
}
return admin.database().ref(`/user_notifications/${toUserRef.key}`).push().set(newNotification).then(() => {
//NEED TO DELETE THE INCOMING "event" HERE TO KEEP DB CLEAN
});
})
}
So in that function in the final "return" of it, after it writes the finalized data to the "/user_notifications" table, I need to delete the event that started the whole thing. Does anyone know how to do that? Thank you.
First off, use .onCreate instead of .onWrite. You only need to read each child when they are first written, so this will avoid undesirable side effects. See the documentation here for more information on the available triggers.
event.data.ref() holds the reference where the event occurred. You can call remove() on the reference to delete it:
return event.data.ref().remove()
The simplest way to achieve this is through calling the remove() function offered by the admin sdk,
you could get the reference to the notification_id through the event, i.e event.params.notification_id then remove it when need be with admin.database().ref('pass in the path').remove(); and you are good to go.
For newer versions of Firebase, use:
return change.after.ref.remove()