フレンドリスト表示順序
概要
フレンドリスト画面における表示順序ロジックの詳細仕様です。追加順(フレンド追加日時ベース)とアクティビティ順(最新同卓履歴順)の2つのソート方式について定義します。
ソート方式
1. 追加順(デフォルト)
フレンドとして追加された日時を基準として表示順序を決定します。
typescript
interface FriendAddedOrder {
sortKey: 'added_date';
direction: 'desc' | 'asc'; // 新しい順 | 古い順
}
interface FriendshipRecord {
friendId: string;
userId: string;
createdAt: Date; // フレンド関係が成立した日時
acceptedAt: Date; // フレンド申請が承認された日時
initiatedBy: string; // 申請を送った側のユーザーID
}
function sortFriendsByAddedDate(
friends: Friend[],
friendshipRecords: FriendshipRecord[],
direction: 'desc' | 'asc' = 'desc'
): Friend[] {
return friends.sort((a, b) => {
const recordA = friendshipRecords.find(r =>
r.friendId === a.id || r.userId === a.id
);
const recordB = friendshipRecords.find(r =>
r.friendId === b.id || r.userId === b.id
);
if (!recordA || !recordB) {
return 0;
}
// acceptedAt(承認日時)を基準とする
const dateA = recordA.acceptedAt.getTime();
const dateB = recordB.acceptedAt.getTime();
return direction === 'desc' ? dateB - dateA : dateA - dateB;
});
}2. アクティビティ順
最新の同卓履歴(一緒にゲームをプレイした日時)を基準として表示順序を決定します。
typescript
interface ActivityOrder {
sortKey: 'last_activity';
direction: 'desc'; // 最新のアクティビティ順のみ
}
interface TableHistory {
tableId: string;
participantIds: string[];
startedAt: Date;
endedAt: Date;
scenarioId: string;
isCompleted: boolean;
}
interface FriendActivity {
friendId: string;
lastPlayedTogether: Date | null;
totalGamesPlayed: number;
recentTableIds: string[];
}
function sortFriendsByActivity(
friends: Friend[],
tableHistories: TableHistory[],
currentUserId: string
): Friend[] {
// 各フレンドのアクティビティ情報を取得
const friendActivities: FriendActivity[] = friends.map(friend => {
// 現在のユーザーとフレンドが一緒にプレイしたテーブルを検索
const sharedTables = tableHistories.filter(table =>
table.participantIds.includes(currentUserId) &&
table.participantIds.includes(friend.id) &&
table.isCompleted
);
// 最新のプレイ日時を取得
const lastPlayedTogether = sharedTables.length > 0
? new Date(Math.max(...sharedTables.map(t => t.endedAt.getTime())))
: null;
// 最新のテーブルID(最大3件)
const recentTableIds = sharedTables
.sort((a, b) => b.endedAt.getTime() - a.endedAt.getTime())
.slice(0, 3)
.map(t => t.tableId);
return {
friendId: friend.id,
lastPlayedTogether,
totalGamesPlayed: sharedTables.length,
recentTableIds
};
});
// アクティビティ順でソート
return friends.sort((a, b) => {
const activityA = friendActivities.find(fa => fa.friendId === a.id);
const activityB = friendActivities.find(fa => fa.friendId === b.id);
// アクティビティ情報がない場合は最後に配置
if (!activityA && !activityB) return 0;
if (!activityA) return 1;
if (!activityB) return -1;
// 一緒にプレイしたことがないフレンドは最後に配置
if (!activityA.lastPlayedTogether && !activityB.lastPlayedTogether) {
return 0; // 追加順での副次ソートを適用
}
if (!activityA.lastPlayedTogether) return 1;
if (!activityB.lastPlayedTogether) return -1;
// 最新のアクティビティ日時で比較
return activityB.lastPlayedTogether.getTime() - activityA.lastPlayedTogether.getTime();
});
}UI制御
ソート切り替えボタン
typescript
interface SortControlProps {
currentSort: 'added_date' | 'last_activity';
onSortChange: (sort: 'added_date' | 'last_activity') => void;
friendCount: number;
}
const SortControl: React.FC<SortControlProps> = ({
currentSort,
onSortChange,
friendCount
}) => {
return (
<div className="friend-list-sort-control">
<div className="sort-buttons">
<button
className={`sort-button ${currentSort === 'added_date' ? 'active' : ''}`}
onClick={() => onSortChange('added_date')}
>
追加順
</button>
<button
className={`sort-button ${currentSort === 'last_activity' ? 'active' : ''}`}
onClick={() => onSortChange('last_activity')}
>
アクティビティ順
</button>
</div>
<div className="friend-count">
{friendCount}人のフレンド
</div>
</div>
);
};フレンドアイテムの表示
typescript
interface FriendItemProps {
friend: Friend;
activity?: FriendActivity;
friendship?: FriendshipRecord;
sortBy: 'added_date' | 'last_activity';
}
const FriendItem: React.FC<FriendItemProps> = ({
friend,
activity,
friendship,
sortBy
}) => {
const getSubText = (): string => {
if (sortBy === 'last_activity') {
if (!activity?.lastPlayedTogether) {
return 'まだ一緒にプレイしていません';
}
return `最後のプレイ: ${formatRelativeTime(activity.lastPlayedTogether)}`;
} else {
if (friendship) {
return `フレンド登録: ${formatRelativeTime(friendship.acceptedAt)}`;
}
return '';
}
};
return (
<div className="friend-item">
<div className="friend-avatar">
<img src={friend.avatarUrl} alt={friend.displayName} />
{friend.isOnline && <div className="online-indicator" />}
</div>
<div className="friend-info">
<div className="friend-name">{friend.displayName}</div>
<div className="friend-subtext">{getSubText()}</div>
</div>
{sortBy === 'last_activity' && activity?.totalGamesPlayed && (
<div className="activity-badge">
{activity.totalGamesPlayed}回プレイ
</div>
)}
</div>
);
};データ取得と缓存
GraphQL クエリ
graphql
query GetFriendsWithActivity($userId: ID!, $includeActivity: Boolean! = false) {
user(id: $userId) {
friends {
id
displayName
avatarUrl
isOnline
level
}
friendshipRecords {
friendId
createdAt
acceptedAt
initiatedBy
}
# アクティビティ順の場合のみ取得
tableHistories @include(if: $includeActivity) {
tableId
participantIds
startedAt
endedAt
scenarioId
isCompleted
}
}
}キャッシュ戦略
typescript
interface FriendsCache {
friends: Friend[];
friendships: FriendshipRecord[];
activities: FriendActivity[];
lastUpdated: Date;
sortedLists: {
[key in 'added_date' | 'last_activity']: Friend[];
};
}
class FriendsService {
private cache: FriendsCache | null = null;
private readonly CACHE_DURATION = 5 * 60 * 1000; // 5分
async getFriendsSorted(
userId: string,
sortBy: 'added_date' | 'last_activity'
): Promise<Friend[]> {
// キャッシュチェック
if (this.isCacheValid() && this.cache?.sortedLists[sortBy]) {
return this.cache.sortedLists[sortBy];
}
// データ取得
await this.refreshFriendsData(userId, sortBy === 'last_activity');
return this.cache?.sortedLists[sortBy] || [];
}
private isCacheValid(): boolean {
if (!this.cache) return false;
const now = new Date().getTime();
const cacheAge = now - this.cache.lastUpdated.getTime();
return cacheAge < this.CACHE_DURATION;
}
private async refreshFriendsData(
userId: string,
includeActivity: boolean
): Promise<void> {
// GraphQL クエリ実行
const data = await this.graphqlClient.query({
query: GET_FRIENDS_WITH_ACTIVITY,
variables: { userId, includeActivity }
});
// ソート済みリストを生成
const sortedLists = {
added_date: sortFriendsByAddedDate(
data.user.friends,
data.user.friendshipRecords
),
last_activity: includeActivity
? sortFriendsByActivity(
data.user.friends,
data.user.tableHistories,
userId
)
: []
};
// キャッシュ更新
this.cache = {
friends: data.user.friends,
friendships: data.user.friendshipRecords,
activities: [], // 必要に応じて計算
lastUpdated: new Date(),
sortedLists
};
}
}パフォーマンス最適化
大量フレンド対応
typescript
interface PaginatedFriends {
friends: Friend[];
hasMore: boolean;
nextCursor?: string;
totalCount: number;
}
// 仮想スクロール用の設定
const VIRTUAL_SCROLL_CONFIG = {
itemHeight: 72, // フレンドアイテムの高さ
overscan: 5, // 画面外にレンダリングするアイテム数
pageSize: 50 // 一度に読み込むフレンド数
};実装注意事項
- リアルタイム更新: WebSocket経由でのオンライン状態更新
- メモリ効率: 大量フレンド時の仮想スクロール実装
- UX: ソート切り替え時のスムーズなアニメーション
- データ整合性: フレンド削除時のキャッシュ無効化