From bbd7618c46da7f274dd9442da57a8f598e52a129 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Fri, 11 Sep 2026 04:10:36 -0600 Subject: [PATCH] Update StoryService and add has_story to AccountTransformer --- app/Services/AccountService.php | 3 +- app/Services/StoryIndexService.php | 56 ++++++++++++++++++++ app/Services/StoryService.php | 60 ++++++++++++++++++---- app/Transformer/Api/AccountTransformer.php | 2 + 4 files changed, 110 insertions(+), 11 deletions(-) diff --git a/app/Services/AccountService.php b/app/Services/AccountService.php index 2c9147bec..f737a8a5c 100644 --- a/app/Services/AccountService.php +++ b/app/Services/AccountService.php @@ -61,7 +61,8 @@ class AccountService $account['location'], $account['note_text'], $account['pronouns'], - $account['website'] + $account['website'], + $account['has_story'], ); $account['avatar_static'] = $account['avatar']; diff --git a/app/Services/StoryIndexService.php b/app/Services/StoryIndexService.php index 85cdf5256..db7863eed 100644 --- a/app/Services/StoryIndexService.php +++ b/app/Services/StoryIndexService.php @@ -706,4 +706,60 @@ class StoryIndexService { return config('database.redis.client') === 'predis' ? ['withscores' => true] : true; } + + public function hasActiveStory(int $profileId): bool + { + return $this->activeStoryCount($profileId) > 0; + } + + public function activeStoryCount(int $profileId): int + { + $min = '('.(time() - self::STORY_TTL); + + return $this->redisInt(fn () => Redis::zcount($this->authorKey($profileId), $min, '+inf')); + } + + /** + * Newest live story id for an author, or null. Same window as above. + */ + public function latestStoryId(int $profileId): ?int + { + $min = '('.(time() - self::STORY_TTL); + + $ids = $this->redisArray(fn () => Redis::zrevrangebyscore( + $this->authorKey($profileId), + '+inf', + $min, + ['limit' => [0, 1]] + )); + + return $ids ? (int) $ids[0] : null; + } + + /** + * Story ids the viewer has seen from this author. Empty if nothing is + * recorded (or the seen key already expired with the stories). + */ + public function seenStoryIds(int $viewerId, int $authorId): array + { + return $this->redisArray(fn () => Redis::smembers($this->seenKey($viewerId, $authorId))); + } + + /** + * True when the index positively knows the viewer saw this story. + * False means "not recorded here", not "definitely unseen". + */ + public function hasSeen(int $viewerId, int $storyId): bool + { + $authorId = Redis::hget($this->storyKey($storyId), 'profile_id'); + + if (! $authorId) { + return false; + } + + return $this->redisBool(fn () => Redis::sismember( + $this->seenKey($viewerId, (int) $authorId), + (string) $storyId + )); + } } diff --git a/app/Services/StoryService.php b/app/Services/StoryService.php index d727bff84..6c1e87d2c 100644 --- a/app/Services/StoryService.php +++ b/app/Services/StoryService.php @@ -45,26 +45,52 @@ class StoryService public static function getStories($id, $pid = null) { - return Story::whereProfileId($id) + $stories = Story::whereProfileId($id) + ->where('active', true) + ->where('expires_at', '>', now()) ->latest() - ->get() - ->map(function ($s) use ($pid) { + ->get(); + + if ($stories->isEmpty()) { + return []; + } + + $seen = $pid + ? self::seenLookup((int) $pid, (int) $id, $stories->pluck('id')->all()) + : []; + + return $stories + ->map(function ($s) use ($seen) { return [ 'id' => (string) $s->id, 'type' => $s->type, 'duration' => 10, - 'seen' => in_array($pid, self::views($s->id)), + 'seen' => isset($seen[$s->id]), 'created_at' => $s->created_at->toAtomString(), 'expires_at' => $s->expires_at->toAtomString(), 'media' => url(Storage::url($s->path)), 'can_reply' => (bool) $s->can_reply, 'can_react' => (bool) $s->can_react, - 'poll' => null, // 'poll' => $s->type == 'poll' ? PollService::storyPoll($s->id) : null, once PollService actually exists. + 'poll' => null, ]; }) ->toArray(); } + private static function seenLookup(int $viewerId, int $authorId, array $storyIds): array + { + $seen = app(StoryIndexService::class)->seenStoryIds($viewerId, $authorId); + + if (empty($seen)) { + $seen = StoryView::whereProfileId($viewerId) + ->whereIn('story_id', $storyIds) + ->pluck('story_id') + ->all(); + } + + return array_flip($seen); + } + public static function views($id) { return StoryView::whereStoryId($id) @@ -74,6 +100,10 @@ class StoryService public static function hasSeen($pid, $sid) { + if (app(StoryIndexService::class)->hasSeen((int) $pid, (int) $sid)) { + return true; + } + $key = self::STORY_KEY.'seen:'.$pid.':'.$sid; return Cache::remember($key, 3600, function () use ($pid, $sid) { @@ -85,8 +115,15 @@ class StoryService public static function latest($pid) { + $id = app(StoryIndexService::class)->latestStoryId((int) $pid); + if ($id) { + return $id; + } + return Cache::remember(self::STORY_KEY.'latest:pid-'.$pid, 3600, function () use ($pid) { $story = Story::whereProfileId($pid) + ->where('active', true) + ->where('expires_at', '>', now()) ->latest() ->first(); @@ -97,6 +134,7 @@ class StoryService public static function delLatest($pid) { Cache::forget(self::STORY_KEY.'latest:pid-'.$pid); + AccountService::del($pid); return Cache::forget('pf:stories:recent-self:'.$pid); } @@ -152,17 +190,19 @@ class StoryService public static function reactIncrement($storyId, $profileId) { $key = 'pf:stories:react-counter:storyid-'.$storyId.':profileid-'.$profileId; - if (Redis::get($key) == null) { - Redis::setex($key, 86400, 1); - } else { - return Redis::incr($key); + + $count = (int) Redis::incr($key); + if ($count === 1) { + Redis::expire($key, 86400); } + + return $count; } public static function reactCounter($storyId, $profileId) { $key = 'pf:stories:react-counter:storyid-'.$storyId.':profileid-'.$profileId; - return (int) Redis::get($key) ?? 0; + return (int) (Redis::get($key) ?? 0); } } diff --git a/app/Transformer/Api/AccountTransformer.php b/app/Transformer/Api/AccountTransformer.php index b14b8037a..297f8fbdf 100644 --- a/app/Transformer/Api/AccountTransformer.php +++ b/app/Transformer/Api/AccountTransformer.php @@ -6,6 +6,7 @@ use App\Models\Profile; use App\Models\User; use App\Models\UserSetting; use App\Services\PronounService; +use App\Services\StoryIndexService; use Illuminate\Support\Facades\Cache; use League\Fractal; @@ -71,6 +72,7 @@ class AccountTransformer extends Fractal\TransformerAbstract 'last_fetched_at' => $local ? null : $profile->last_fetched_at?->toJSON(), 'pronouns' => PronounService::get($profile->id), 'location' => $profile->location, + 'has_story' => app(StoryIndexService::class)->hasActiveStory($profile->id), ]; $moved = $this->resolveMoved($profile);