先週リリースしたNotespodというメモアプリでフレンドシステムを実装しました(後付けで)
備忘録もかねて記事にしておこうかと思った次第です。アプリはFlutterですが、この実装自体はFlutterに依存するものではないと思います。
あくまで一例ですが、参考になれば幸いです(もっといいやり方あったら教えてください)
フレンドシステムとは
基本的にゲームに多いんですが、ソシャゲとそれ以外では仕組みが異なる場合が多いです。自分のアプリで実装したのはフレンドコードを登録するタイプのものです。プラットフォームの機能でよくあるやつです。コード以外にもIDとかタグとか言い方は複数ありますが、意味はどれも同じようなものです。
- フレンドになりたいユーザーのフレンドコードを入力
- 該当するコードのユーザーにフレンド申請が送られる
- 申請を受け取った相手はフレンドになることもできるし拒否することもできる
- フレンドになったら一緒にゲームを楽しむことができる
自分のアプリの場合はゲームではなくメモを交換できるようになるわけですが、今回はこの1~4までを簡単にまとめてみます。
Firestore の階層構成
users - {user} - sentRequests - {sentRequest}
- gotRequests - {gotRequest}
- friends - {friend}
sentRequest
は、送信したフレンド申請です。
gotRequest
は、受け取ったフレンド申請です。
friend
は、フレンドです。
UserA がUserB にフレンド申請を送った場合、UserAのsentRequests
とUserBのgotRequests
にそれぞれドキュメントが書き込まれます。
特にgotRequests
コレクションをsnapshotで常に更新を取得する形にすると、申請が来たタイミングで何らかのアクションが起こせていい感じです。
もちろん、UserA がUserB のコレクションに直接書き込むことはなく、処理はすべてCloud Functions
で行われます。
セキュリティルールですが、今回のようにFunctions
で処理する場合、readだけ残してwriteは消してしまっていいと思います。
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{userId} {
match /sentRequests/{sentRequestId} {
allow read : if request.auth.uid == userId;
}
match /gotRequests/{gotRequestId} {
allow read : if request.auth.uid == userId;
}
match /friends/{friendId} {
allow read : if request.auth.uid == userId;
}
}
}
}
1. フレンドコードを入力して送信
自分のアプリではユーザー検索のような機能はないので、フレンドコードはSNS等で教えてもらう想定です。それを入力してもらいFunctions
に送信します
final functions = FirebaseFunctions.instanceFor(region: 'asia-northeast1');
final callable = functions.httpsCallable('requestFriend');
final result = await callable({'friendCode': 'hogehoge'});
if (result.data == 'success'){
print('申請完了');
}
if (result.data == 'none') {
print('ユーザーが見つかりませんでした');
}
2. 相手にフレンド申請を送る
Functions
の処理は以下の感じです。アプリではここに「すでに申請中」とか「ブロック中」とかの処理もあるんですが、省略してます。
import admin = require("firebase-admin");
const db = admin.firestore();
exports.requestFriend = functions
.region("asia-northeast1")
.https.onCall(async (data, context) => {
const friendCode: string = data.friendCode;
const sentRequestUserId = context.auth!.uid;
const gotRequestUserDocs = await db.collection('users')
.where("friendCode", "==", friendCode)
.get();
let result=''
if (gotRequestUserDocs.empty) {
result = "none";
} else {
const gotRequestUser = gotRequestUserDocs.docs[0]; // UserB
await gotRequestUser.ref
.collection('gotRequests')
.doc(sentRequestUserId) // 送り側のIdにすると扱いやすい
.set({id:sentRequestUserId}) // ほか必要なフィールドをセット
await db.collection('users').doc(sentRequestUserId) // UserA
.collection('sentRequests')
.doc(gotRequestUser.id) // 受け手側のIdにすると扱いやすい
.set({id:gotRequestUser.id}) // ほか必要なフィールドをセット
result = 'success';
}
return result;
})
3. 受け取った申請を処理
フレンド申請を受け取ったUserB は、申請を受けるか拒否するか選択できます。拒否する場合は、基本的にはUserAのsentRequest
とUserBのgotRequest
をそれぞれ削除するだけでいいかと思います。実際にはここで「この相手をブロックする」といった選択肢も必要になりますが、その場合はgotRequest
を削除せずにisBlocked
フィールドをtrueにした状態で残しておくと扱いやすく感じました(申請時にgotRequests
コレクションにすでに同じIDのドキュメントがあると、「すでに申請中」か「ブロック中」のいずれかになる)
final functions = FirebaseFunctions.instanceFor(region: 'asia-northeast1');
final callable = functions.httpsCallable('declineFriendRequest');
final result = await callable({
'sentRequestUserId': 'hugahuga',
'blockRequest': blockRequest // bool
});
if (result.data == 'decline'){
print('拒否しました');
}
if (result.data == 'block') {
print('ブロックしました');
}
import admin = require("firebase-admin");
const db = admin.firestore();
exports.declineFriendRequest = functions
.region("asia-northeast1")
.https.onCall(async (data, context) => {
const gotRequestUserId = context.auth!.uid;
const sentRequestUserId = data.sentRequestUserId;
const blockRequest: boolean = data.blockRequest;
let result =''
await db.collection('users')
.doc(sentRequestUserId) // UserA
.collection('sentRequests')
.doc(gotRequestUserId)
.delete();
if(blockRequest){
// ブロックする場合
await db.collection('users')
.doc(gotRequestUserId) // UserB
.collection('gotRequests')
.doc(sentRequestUserId)
.update({isBlocked: true})
result = 'block'
} else{
// 拒否する場合
await db.collection('users')
.doc(gotRequestUserId) // UserB
.collection('gotRequests')
.doc(sentRequestUserId)
.delete()
result = 'decline'
}
return result
})
フレンドになる場合は、それぞれのfriends
コレクションにお互いのドキュメントをセットすることになります。不要になるのでUserAのsentRequest
とUserBのgotRequest
はそれぞれ削除されます。上の処理と同じなのでコードは省略します
import admin = require("firebase-admin");
const db = admin.firestore();
exports.registrateFriendRequest = functions
.region("asia-northeast1")
.https.onCall(async (data, context) => {
const gotRequestUserId = context.auth!.uid;
const sentRequestUserId = data.sentRequestUserId;
await db.collection('users')
.doc(sentRequestUserId) // UserA
.collection('friends')
.doc(gotRequestUserId)
.set({id:gotRequestUserId}) // UserB
await db.collection('users')
.doc(gotRequestUserId) // UserB
.collection('friends')
.doc(sentRequestUserId)
.set({id:sentRequestUserId}) // UserA
})
4. フレンドにメモを送る
複数のフレンドに送るコードです。Functions
にはメモではなくメモのパスを送ってますが、こっちのほうが処理が調整しやすく感じました。実際のアプリ内ではこれに加えて添付データの画像やら動画やらに関連したCloud storage
の処理と通知周りのCloud Messaging
の処理が入ってるんですが長いので省略
import admin = require("firebase-admin");
const db = admin.firestore();
exports.sendNoteToFriends = functions
.region("asia-northeast1")
.https.onCall(async (data, context) => {
const senderUserId = context.auth!.uid;
const notePath:string = data.notePath;
const friends = await db.collection('users')
.doc(senderUserId)
.collection('friends')
.get()
const note = await db.doc(notePath).get();
const noteData = note.data() as NOTE
const promises = friends.docs.map(async (friend)=>{
await db.collection('users')
.doc(friend.id)
.collection('notes')
.doc(noteData.id)
.set({...noteData})
})
await Promise.allSettled(promises);
})
もちろん、データは上階層に置いて、フレンドにはパスだけ送るのもアリかと思います。
終わりに
完全に余談なんですが、アプリ内での日本語の表現は少し気を付けました。例えば、**「フレンドを削除する」ってちょっと酷くないかと思ったので、「フレンド登録を解除する」にしてみたり、たまに見かける「承諾」や「許可」も何か違う気がして「登録」**にしてみたりとかです。
記事内容はツッコミ歓迎です