Skip to content

Commit 70780a4

Browse files
authored
Improve community search with focused, paginated results (#73)
* Add focused community search and result pagination * Keep the search showcase in one README section
1 parent 2a551d2 commit 70780a4

11 files changed

Lines changed: 545 additions & 69 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ All notable project changes will be documented here.
66

77
### Added
88

9+
- Focused community search with navigable Posts, Spaces, People and Topics
10+
filters, bounded previous/next pagination, deterministic ordering, per-page
11+
visibility enforcement, responsive category navigation and recoverable empty
12+
states without a new search service or database migration.
913
- A privacy-safe Community Onboarding journey for newly verified members, with
1014
four outcome-based steps, existing-policy Space and People suggestions,
1115
invitation-aware entry, an optional dismissal preference, personal-export

README.md

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,33 @@ instead of treating dark mode as a simple color inversion.
133133

134134
![Lineweb Social native dark mode across laptop and mobile profile highlights](docs/mockups/readme-dark.webp)
135135

136+
### Find the conversation beyond the first few results
137+
138+
Start with a community-wide overview, then focus on Posts, Spaces, People or
139+
Topics. Shareable search URLs, previous/next pages and a swipeable mobile
140+
category rail help members reach older matches. Every page reapplies current
141+
visibility and safety rules, with no separate search service to configure.
142+
143+
![Community search with focused categories and visible topic counts](docs/screenshots/search-desktop.jpg)
144+
145+
<table>
146+
<tr>
147+
<td width="72%" valign="top">
148+
<img src="docs/screenshots/search-dark-desktop.jpg" alt="Community search in dark mode" />
149+
</td>
150+
<td width="28%" valign="top">
151+
<img src="docs/screenshots/search-mobile.jpg" alt="Focused People search with a swipeable category rail on mobile" />
152+
</td>
153+
</tr>
154+
<tr>
155+
<td align="center"><sub>The same search experience in dark mode</sub></td>
156+
<td align="center"><sub>Focused discovery on mobile</sub></td>
157+
</tr>
158+
</table>
159+
160+
See the [community search contract](docs/community-search.md) for ordering,
161+
privacy and pagination boundaries.
162+
136163
### Timely Stories without surveillance mechanics
137164

138165
Members can share a short thought, one private normalized image, or both inside
@@ -420,21 +447,6 @@ operations rather than the browser.
420447
</tr>
421448
</table>
422449

423-
<table>
424-
<tr>
425-
<td width="68%">
426-
<img src="docs/screenshots/search-desktop.jpg" alt="Lineweb Social policy-filtered global search on desktop" />
427-
</td>
428-
<td width="32%">
429-
<img src="docs/screenshots/search-mobile.jpg" alt="Lineweb Social policy-filtered global search on mobile" />
430-
</td>
431-
</tr>
432-
<tr>
433-
<td align="center"><sub>Grouped search across visible community content</sub></td>
434-
<td align="center"><sub>Focused mobile discovery</sub></td>
435-
</tr>
436-
</table>
437-
438450
<table>
439451
<tr>
440452
<td width="68%">
@@ -569,7 +581,7 @@ makes the final decision.
569581
| Profiles | Editable member identity, headlines, author-curated three-post highlights, real chronological activity, public/shared/private visibility, and discovery opt-out. |
570582
| Spaces | Public/private/hidden communities, searchable directory, join/leave rules, account-specific and limited-use shareable invitations, roles, ownership transfer, member removal, bounded curated highlights, and official events with private RSVPs. |
571583
| Publishing | Focused composer, author-only drafts, private four-image WebP galleries with per-image alt text and swipe navigation, bounded private-first polls, 24-hour Space Stories with no viewer tracking, chronological posts, privacy-safe quote posts and reposts, comments, permanent conversations, and author controls. |
572-
| Discovery | Policy-filtered search across posts, Spaces, and People; Unicode hashtags; chronological topic trails; and privacy-aware Following. |
584+
| Discovery | Policy-filtered search across posts, Spaces, People and Topics, focused category filters, navigable result pages, Unicode hashtags, chronological topic trails and privacy-aware Following. |
573585
| Interactions | Typed Like, Celebrate, and Insightful reactions, private Saved Posts, follows, mentions, one-level direct replies, same-Space quote/repost actions, comments, copy links, and conversation shortcuts. |
574586
| Messaging | Canonical one-to-one conversations, participant-only history, unread state, block-aware delivery, and responsive inbox/thread views. |
575587
| Trust and safety | Mute, mutual block, Safety recovery, post/comment reporting, Space moderation queues, Direct Message reporting, and audited decisions. |

app/Community/CommunitySearch.php

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ final class CommunitySearch
1515

1616
public const int RESULT_LIMIT = 8;
1717

18+
public const int MAX_PAGE = 1000;
19+
1820
public function __construct(private readonly VisiblePostQuery $visiblePosts) {}
1921

2022
/**
@@ -25,33 +27,36 @@ public function __construct(private readonly VisiblePostQuery $visiblePosts) {}
2527
* topics: list<array{name: string, url: string, visiblePostCount: int}>
2628
* }
2729
*/
28-
public function search(User $viewer, string $query): array
30+
public function search(User $viewer, string $query, string $type = 'all', int $page = 1): array
2931
{
3032
if (mb_strlen($query) < self::MINIMUM_QUERY_LENGTH) {
3133
return $this->emptyResults();
3234
}
3335

3436
$pattern = "%{$query}%";
37+
$limit = self::RESULT_LIMIT + ($type === 'all' ? 0 : 1);
38+
$offset = $type === 'all' ? 0 : ($page - 1) * self::RESULT_LIMIT;
3539
$topicQuery = ltrim($query, '#');
3640
$visiblePostIds = $this->visiblePosts
3741
->forSearch($viewer)
3842
->select('posts.id');
3943

40-
$posts = $this->visiblePosts
44+
$posts = in_array($type, ['all', 'posts'], true) ? $this->visiblePosts
4145
->forSearch($viewer)
4246
->whereLike('posts.body', $pattern)
4347
->latest('posts.published_at')
4448
->latest('posts.id')
45-
->limit(self::RESULT_LIMIT)
46-
->get();
49+
->offset($offset)
50+
->limit($limit)
51+
->get() : collect();
4752

4853
$visibleAuthorIds = User::query()
4954
->visibleTo($viewer)
5055
->whereKey($posts->pluck('user_id')->unique())
5156
->pluck('id')
5257
->all();
5358

54-
$spaces = Space::query()
59+
$spaces = in_array($type, ['all', 'spaces'], true) ? Space::query()
5560
->discoverableBy($viewer)
5661
->where(function (Builder $spaces) use ($pattern): void {
5762
$spaces
@@ -64,10 +69,12 @@ public function search(User $viewer, string $query): array
6469
])
6570
->withCount('members')
6671
->orderBy('name')
67-
->limit(self::RESULT_LIMIT)
68-
->get();
72+
->orderBy('id')
73+
->offset($offset)
74+
->limit($limit)
75+
->get() : collect();
6976

70-
$people = User::query()
77+
$people = in_array($type, ['all', 'people'], true) ? User::query()
7178
->discoverableBy($viewer)
7279
->whereKeyNot($viewer->getKey())
7380
->where(function (Builder $people) use ($pattern): void {
@@ -88,10 +95,12 @@ public function search(User $viewer, string $query): array
8895
),
8996
])
9097
->orderBy('name')
91-
->limit(self::RESULT_LIMIT)
92-
->get();
98+
->orderBy('id')
99+
->offset($offset)
100+
->limit($limit)
101+
->get() : collect();
93102

94-
$topics = mb_strlen($topicQuery) >= self::MINIMUM_QUERY_LENGTH
103+
$topics = in_array($type, ['all', 'topics'], true) && mb_strlen($topicQuery) >= self::MINIMUM_QUERY_LENGTH
95104
? Topic::query()
96105
->whereLike('topics.name', "%{$topicQuery}%")
97106
->whereHas(
@@ -105,7 +114,9 @@ public function search(User $viewer, string $query): array
105114
])
106115
->orderByDesc('visible_posts_count')
107116
->orderBy('name')
108-
->limit(self::RESULT_LIMIT)
117+
->orderBy('id')
118+
->offset($offset)
119+
->limit($limit)
109120
->get()
110121
: collect();
111122

app/Http/Controllers/SearchController.php

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use App\Community\CommunitySearch;
66
use App\Http\Requests\SearchRequest;
77
use App\Models\User;
8+
use Illuminate\Pagination\Paginator;
89
use Illuminate\Support\Str;
910
use Inertia\Inertia;
1011
use Inertia\Response;
@@ -17,11 +18,32 @@ public function __invoke(SearchRequest $request, CommunitySearch $search): Respo
1718
$viewer = $request->user();
1819
$value = $request->validated('q');
1920
$query = Str::squish(is_string($value) ? $value : '');
21+
/** @var 'all'|'posts'|'spaces'|'people'|'topics' $type */
22+
$type = $request->string('type')->toString() ?: 'all';
23+
$page = $type === 'all' ? 1 : max(1, $request->integer('page', 1));
24+
$results = $search->search($viewer, $query, $type, $page);
25+
$pagination = null;
26+
27+
if ($type !== 'all' && mb_strlen($query) >= CommunitySearch::MINIMUM_QUERY_LENGTH) {
28+
$paginator = new Paginator($results[$type], CommunitySearch::RESULT_LIMIT, $page, [
29+
'path' => route('search'),
30+
'query' => ['q' => $query, 'type' => $type],
31+
]);
32+
$results[$type] = $paginator->items();
33+
$pagination = [
34+
'currentPage' => $page,
35+
'previousUrl' => $paginator->previousPageUrl(),
36+
'nextUrl' => $page < CommunitySearch::MAX_PAGE ? $paginator->nextPageUrl() : null,
37+
'limitReached' => $page === CommunitySearch::MAX_PAGE && $paginator->hasMorePages(),
38+
];
39+
}
2040

2141
return Inertia::render('search/index', [
2242
'query' => $query,
43+
'type' => $type,
2344
'minimumQueryLength' => CommunitySearch::MINIMUM_QUERY_LENGTH,
24-
'results' => $search->search($viewer, $query),
45+
'results' => $results,
46+
'pagination' => $pagination,
2547
]);
2648
}
2749
}

app/Http/Requests/SearchRequest.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace App\Http\Requests;
44

5+
use App\Community\CommunitySearch;
56
use Illuminate\Foundation\Http\FormRequest;
67

78
class SearchRequest extends FormRequest
@@ -18,6 +19,8 @@ public function rules(): array
1819
{
1920
return [
2021
'q' => ['nullable', 'string', 'max:100'],
22+
'type' => ['nullable', 'string', 'in:all,posts,spaces,people,topics'],
23+
'page' => ['nullable', 'integer', 'min:1', 'max:'.CommunitySearch::MAX_PAGE],
2124
];
2225
}
2326
}

docs/community-search.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Community search
2+
3+
Verified, active members can search currently accessible posts, Spaces, people
4+
and topics at `/search`. The existing member throttle applies to every page.
5+
6+
## Overview and focused results
7+
8+
- `q` accepts at most 100 characters and is whitespace-normalized. Queries shorter
9+
than two Unicode characters show a starting state without pagination.
10+
- `type` is one of `all`, `posts`, `spaces`, `people` or `topics`. The default
11+
overview shows up to eight matches per category. A category link opens its
12+
focused results without searching unrelated categories.
13+
- Focused views show eight results per page, with previous/next links that keep
14+
the normalized query and category in the URL. Changing category or submitting
15+
a new query starts at page one. Browser back/forward restores the matching
16+
query and view. No client-side search-history store is introduced.
17+
- Pagination fetches one additional policy-filtered record to determine whether
18+
a next page exists. It does not run a global result-count query.
19+
- `page` must be an integer from 1 through 1000. At the bounded browsing limit,
20+
members are prompted to narrow their query. The overview ignores `page`.
21+
- Posts remain ordered by publication time and ID descending. Spaces and people
22+
sort by name then ID. Topics sort by currently visible post count, name and ID.
23+
Results are not a frozen snapshot: membership, moderation, blocking and newly
24+
published content may change what appears between page requests.
25+
26+
## Visibility and privacy
27+
28+
Every page applies the existing post, Space, discovery and relationship policies
29+
before its offset and limit. Drafts, hidden content, inaccessible Spaces and
30+
blocked authors cannot fill slots or leak through pagination. Topic counts
31+
include only posts the current member can see. People retain the existing
32+
discovery opt-out and profile visibility rules; muting does not itself hide a
33+
person from the People directory.
34+
35+
Payloads remain explicit presentation fields, not serialized member records.
36+
No email address, moderation detail or global private-content count is added.
37+
Search terms are present in navigable URLs, so normal browser history and
38+
operator access-log policies still apply. No search analytics or retention
39+
system is introduced by this feature.
40+
41+
The mobile category rail scrolls within its container, the active link uses
42+
`aria-current`, and pagination controls have explicit labels and disabled
43+
states. Empty later pages keep a route back to page one when content or access
44+
changes. No external search service, schema migration or API v1 change is needed.
235 KB
Loading
69.2 KB
Loading

docs/screenshots/search-mobile.jpg

334 Bytes
Loading

0 commit comments

Comments
 (0)