🍔

FlutterとSupabaseでフォロー機能(タイムライン)を実装してみた

2024/12/01に公開1

陸上競技・短距離走の選手向け練習記録アプリ「スプトレ」の開発・運用しています。
SNS機能も搭載されており、フォロー機能を実装しましたので実装内容をご紹介します。

スプトレのダウンロードはこちらから👇
iOS版, Android版

スプトレのフォロー機能の要件は以下の通りです。

  • ユーザーをフォローできること
  • フォローしたユーザーの投稿を確認できること(タイムライン)
    • ミュートしたユーザーの投稿は確認できないこと
  • ユーザーのフォロー/フォロワー数を確認できること
  • フォロー中/フォロワーリストを確認できること
  • ブロックした/されているユーザーはフォローできないこと
  • フォローされているユーザーのブロック解除できること
  • フォローされたユーザーへフォロー通知が送られること
タイムライン プロフィール フォロワーリスト お知らせ
スプトレ スプトレ スプトレ スプトレ

テーブル設計

スプトレはSupabaseのDB(PostgreSQL)を利用しています。テーブル設計を以下の通りです。

カラム 説明
id uuid 主キー
follower uuid フォローした人(外部キーにプロフィールテーブル)
followee uuid フォローされた人(外部キーにプロフィールテーブル)
created_at timestamp with time zone フォローした日

SQLコマンドは以下の通りです。followerfolloweeの外部キーにathletesを設定しています。athletesはユーザーのプロフィールテーブルです。

バリデーションではUNIQUE句を使って重複を防ぎます。

RLSではブロックした/されているユーザーはフォローできないことを満たすため、サブクエリでブロックリストテーブルを見て作成可否を判断しています。

follow.sql
create table
    follow (
        id uuid primary key default uuid_generate_v4(),
        follower uuid not null default auth.uid() references athletes(id) on delete cascade on update cascade,
        followee uuid not null default auth.uid() references athletes(id) on delete cascade on update cascade,
        created_at timestamp with time zone not null default timezone('utc'::text, now())
    );

-- バリデーション
alter table follow
    add constraint follow_unique UNIQUE (follower, followee);

-- インデックス
create index
    follow_index on follow (
        follower,
        followee,
        created_at
	);

-- RLSを有効にする
alter table follow enable row level security;

-- RLS設定
create policy "Insert policy." on follow as permissive for 
insert to public with check (
    auth.uid() = follower and 
    (
        not exists (
            select 1 from blocked_users b
            where b.athlete = auth.uid() and b.blocked_athlete = follow.followee
        ) and
        not exists (
            select 1 from blocked_users b
            where b.blocked_athlete = auth.uid() and b.athlete = follow.followee
        )
    )
);

create policy "Select policy." on follow as permissive for 
select to public using (true);

create policy "Update policy." on follow as permissive for 
update to public using (false);

create policy "Delete policy." on follow as permissive for 
delete to public using (auth.uid() = follower or auth.uid() = followee);

アプリの実装

Supabaseに対するアプリの実装は以下の通りです。

フォローする
Future<String?> createFollow({
    required String followee,
}) async {
    final userId = supabase.auth.currentSession?.user.id;
    if (userId == null) {
      throw AppException(title: 'ログインしてください');
    }
    final res = await supabase
        .from('follow')
        .insert({
          'follower': userId,
          'followee': followee,
        })
        .select('id')
        .maybeSingle();
    
    return res?['id'] as String?;
}
フォロー解除
Future<String?> deleteFollow({
    required String follower,
    required String followee,
}) async {
    final res = await supabase
        .from('follow')
        .delete()
        .eq('follower', follower)
        .eq('followee', followee)
        .select('id')
        .maybeSingle();
    return res?['id'] as String?;
}
フォロー中の数を取得(ユーザーがフォローしている人数)
Future<int> fetchFollowerCount({required String userId}) async {
    final query = supabase.from('follow').select('id').eq('follower', userId);
    final res = await query.count();
    return res.count;
}
フォロワー数を取得(ユーザーがフォローされてる人数)
Future<int> fetchFolloweeCount({required String userId}) async {
    final query = supabase.from('follow').select('id').eq('followee', userId);
    final res = await query.count();
    return res.count;
}

フォロー中/フォロワーリストの取得

フォロー中/フォロワーリストを取得の際に、自身がそのユーザーをフォローしているかの状態も取得したかったため、VIEWを用いて実装しました[1]。自分のフォロー中/フォロワーリストであれば、不要ですが、他のユーザーのフォロー中/フォロワーリストを見た際に、自分がフォローしているかどうか分かるようにするためです。

VIEW内のサブクエリでis_followingを返却するようにしました。

view_followers.sql
create or replace view followers with (security_invoker = on) as
select 
    f.id as id,
    f.follower as follower_id,
    f.followee as followee_id,
    jsonb_build_object(
        'id', a1.id,
        'name', a1.name,
        'account_id', a1.account_id,
        'image', a1.image
    ) as follower,
    jsonb_build_object(
        'id', a2.id,
        'name', a2.name,
        'account_id', a2.account_id,
        'image', a2.image
    ) as followee,
    exists (
        select 1
        from follow f2
        where f2.follower = auth.uid() and f2.followee = f.followee
    ) as is_following,
    f.created_at as created_at
from 
    follow f
join 
    athletes a1 on f.follower = a1.id
join 
    athletes a2 on f.followee = a2.id;
view_followees.sql
create or replace view followees with (security_invoker = on) as
select 
    f.id as id,
    f.follower as follower_id,
    f.followee as followee_id,
    jsonb_build_object(
        'id', a1.id,
        'name', a1.name,
        'account_id', a1.account_id,
        'image', a1.image
    ) as follower,
    jsonb_build_object(
        'id', a2.id,
        'name', a2.name,
        'account_id', a2.account_id,
        'image', a2.image
    ) as followee,
    exists (
        select 1
        from follow f2
        where f2.follower = auth.uid() and f2.followee = f.follower
    ) as is_following,
    f.created_at as created_at
from 
    follow f
join 
    athletes a1 on f.follower = a1.id
join 
    athletes a2 on f.followee = a2.id;
フォロー中のリストを取得
Future<List<Follow>> fetchFollowers({
    required String userId,
    required int startIndex,
    required int limit,
}) async {
    final endIndex = startIndex + limit - 1;
    
    final res = await supabase
        .from('followers')
        .select()
        .eq('follower_id', userId)
        .order('created_at', ascending: false)
        .limit(limit)
        .range(startIndex, endIndex);
    
    return res.map(Follow.fromJson).toList();
}
フォロワーのリストを取得
Future<List<Follow>> fetchFollowees({
    required String userId,
    required int startIndex,
    required int limit,
}) async {
    final endIndex = startIndex + limit - 1;
    
    final res = await supabase
        .from('followees')
        .select()
        .eq('followee_id', userId)
        .order('created_at', ascending: false)
        .limit(limit)
        .range(startIndex, endIndex);
    
    return res.map(Follow.fromJson).toList();
}

フォローしたユーザーの投稿(タイムライン)

ブロックとミュートしているユーザーの投稿は除外して取得します。

ブロックに関して、ユーザーの投稿はdiariesテーブルで管理しており、diariesのRLSでブロックしたユーザーの投稿は返却しないよう設定しています。

diariesに対するRLSのselect設定
create policy "Select diaries policy for blocked user." on diaries as restrictive for 
select to public using (
    not exists (
        select 1 from blocked_users b
        where b.athlete = auth.uid() and b.blocked_athlete = diaries.athlete
    ) and
    not exists (
        select 1 from blocked_users b
        where b.blocked_athlete = auth.uid() and b.athlete = diaries.athlete
    )
);

INNER JOINでフォローしているユーザーの投稿を取得します。

ミュートは、RLSで制御する程ではないため、サブクエリを用いてミュートしたユーザーの投稿を除外します。

これらをVIEWを用いて実装しました。フォローしたユーザーの投稿の実装は以下の通りです。

view_diaries_of_follow.sql
create or replace view diaries_of_follow with (security_invoker = on) as
select 
  d.* from diaries d 
inner join
  follow f on d.athlete = f.followee
where
  f.follower = auth.uid() and d.athlete not in (
    select muted_athlete from muted_users where athlete = auth.uid()
  );
フォローしたユーザーの投稿を取得
Future<List<Diary>> fetchDiariesOfFollow({
    required int startIndex,
    required int limit,
}) async {
    final userId = supabase.auth.currentSession?.user.id;
    final endIndex = startIndex + limit - 1;
    final res = await supabase.from('diaries_of_follow').select(
          '*,'
          'athlete (id, account_id, name, image),'
          'sprint_spot (id, title, prefecture_type (id, value)),'
          'weather_type (id, value),'
          'diary_health (id, condition_rating(id, value), sleep_quality_rating(id, value), muscle_condition_rating(id, value), is_public),'
          'time_trial_records (*),'
          'like_count:diary_likes (count),'
          'diary_likes:diary_likes (athlete)',
        )
        .eq('is_public', true) // RLSの仕様により、自身の認証状態からのリクエストの場合、自分の非公開投稿も取得できてしまうため、明示的に公開状態を指定する
        .order('id', ascending: false)
        .limit(limit)
        .range(startIndex, endIndex);

    return res.map((e) => Diary.fromSupabaseJson(userId, e)).toList();
}

ブロック解除

既にフォローしているユーザーをブロックした際は、フォローを解除するようにします。

フォロー状態を取得
Future<Follow?> fetchFollower({
    required String follower,
    required String followee,
}) async {
    final res = await supabase.from('follow').select(
          '*,'
          'follower (id, account_id, name, image),'
          'followee (id, account_id, name, image)',
        )
        .eq('follower', follower)
        .eq('followee', followee)
        .maybeSingle();
    
    if (res == null) {
      return null;
    }
    
    return Follow.fromJson(res);
}
ユーザーをブロックする(ブロック解除)
Future<void> block() async {
    final myUserId = ...; // 自分のID
    final targetUserId = ...; // ブロックするユーザーID

    // ブロック処理
    ...(省略)
    
    // フォロー解除
    final follows = await Future.wait([
        // 自分がブロックするユーザーをフォローしているか取得
        fetchFollower(
            follower: myUserId,
            followee: targetUserId,
        ),
        // ブロックするユーザーが自分をフォローしているか取得
        fetchFollower(
            follower: targetUserId,
            followee: myUserId,
        ),
    ]);

    // フォロー状態があれば全て解除する
    for (final follow in follows) {
      if (follow == null) {
        continue;
      }
      final res = await unfollow(
        follower: follow.follower.id,
        followee: follow.followee.id,
      );
    }
}

フォローされたユーザーへフォロー通知が送られる

フォローするとSupabaseのEdge Functionsが動作し、通知テーブル(user_notifications)に通知データの作成とフォローされたユーザーに対してプッシュ通知を飛ばすようにしています。

Edge Functionsの実装は以下の通りです。

フォローされたユーザーへ通知を送る
...

Deno.serve(async (req) => {
  const reqBody = await req.json()
  const { record } = reqBody

  const follow = record as Follow
  const fromUserId = follow.follower as string
  const type = noticeType.follow

  // いいねした人を取得
  const { data: athlete } = await supabaseClient
    .from('athletes')
    .select('id, account_id, name, image')
    .eq('id', fromUserId)
    .maybeSingle()

  // 自分自身の場合は何もしない
  const toUserId = follow.followee as string
  if (fromUserId == toUserId) {
    return new Response(undefined, { status: 200 })
  }

  // 通知に記録
  const name = athlete?.name ?? 'スプリンター'

  const title = `ユーザーからのフォロー`
  const body = `${name}さんにフォローされました`
  await supabaseClient.from('user_notifications').insert({
    athlete: toUserId,
    from_athlete: fromUserId,
    title: title,
    body: body,
    notice_type: type,
    metadata: follow,
  })

  // プッシュ通知
  const { data: notificationSetting } = await supabaseClient
    .from('user_notification_settings')
    .select('*')
    .eq('id', toUserId)
    .maybeSingle()
  const fcmToken = notificationSetting?.fcm_token
  const isReceived = notificationSetting?.is_received_follow ?? true
  if (isReceived && fcmToken) {
    const serviceAccount = await getServiceAccount()
    const accessToken = await getAccessToken({
      clientEmail: serviceAccount.client_email,
      privateKey: serviceAccount.private_key,
    })

    try {
      const athleteImage = athlete?.image as StorageFile | undefined
      await sendMessage({
        projectId: serviceAccount.project_id,
        accessToken: accessToken,
        fcmToken: fcmToken,
        title: title,
        body: body,
        type: type,
        badge: 1,
        data: {
          from_user_id: fromUserId,
          to_user_id: toUserId,
          ...(athleteImage ? { image_url: athleteImage.url, image_path: athleteImage.path } : {}),
        },
      })
    } catch (e) {
      console.warn(e)
    }
  }

  return new Response(undefined, { status: 200 })
})

プッシュ通知はFirebase Messagingを利用します。SupabaseでFirebase Messagingを利用するためにこの動画を参考に実装しました。

プッシュ通知を送るために必要なadmin情報を取得する
import { JWT } from 'npm:google-auth-library@9.14.1'

export const getServiceAccount = async () => {
  const { default: serviceAccount } = await import('../service-account.json', { with: { type: 'json' } })
  return serviceAccount
}

// https://firebase.google.com/docs/cloud-messaging/migrate-v1?hl=ja#provide-credentials-manually
export const getAccessToken = ({
  clientEmail,
  privateKey,
}: {
  clientEmail: string
  privateKey: string
}): Promise<string> => {
  return new Promise((resolve, reject) => {
    const jwtClient = new JWT({
      email: clientEmail,
      key: privateKey,
      scopes: ['https://www.googleapis.com/auth/firebase.messaging'],
    })
    jwtClient.authorize((err, tokens) => {
      if (err) {
        reject(err)
        return
      }
      resolve(tokens!.access_token!)
    })
  })
}
プッシュ通知を送る
export const sendMessage = async ({
  projectId,
  accessToken,
  fcmToken,
  title,
  body,
  badge,
  type,
  data,
}: SendMessage): Promise<Response> => {
  const _body = body.slice(0, 100) // 文字数制限があるため
  return await sendWithToken(projectId, accessToken, {
    token: fcmToken,
    title: title,
    body: _body,
    badge: badge,
    isSound: true,
    data: {
      ...(type != null ? { type: type } : {}),
      click_action: 'FLUTTER_NOTIFICATION_CLICK',
      ...data,
    },
  })
}

export const sendWithToken = async (
  projectId: string,
  accessToken: string,
  dto: Token & BaseMessage,
): Promise<Response> => {
  const url = `https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`
  const result = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` },
    body: JSON.stringify({
      message: {
        data: dto.data,
        token: dto.token,
        notification: {
          title: dto.title,
          body: dto.body,
          imageUrl: dto.imageUrl,
        },
        android: {
          notification: {
            title: dto.title,
            body: dto.body,
          },
        },
        apns: {
          payload: {
            aps: {
              alert: {
                title: dto.title,
                subtitle: dto.subtitle,
                body: dto.body,
              },
              badge: dto.badge,
              sound: dto.isSound ? 'default' : undefined,
            },
          },
        },
      },
    }),
  })
  if (result.status == 200) {
    console.log('Successfully sent message:', result)
  } else {
    console.log('status', result.status, result)
  }
  return result
}

アプリ側は通知テーブル(user_notifications)を取得して、通知種類によって表示するリストタイルを切り替えて表示しています。

おわりに

もっとこうしたほうが良いよといったご意見ありましたら教えてください。

フォロー機能で活用したサブクエリがパフォーマンスにどの程度影響するのか気になっています。今のところ取得に時間がかかるなどの影響はなさそうですが、サブクエリを使わない別のアプローチがあれば知りたいです。

脚注
  1. SupabaseのSDKはサブクエリの指定ができないため、サブクエリを利用するためにVIEWを活用しています。 ↩︎

株式会社Never

Discussion