Skip to content

Commit dc2e44d

Browse files
fix(birdy): fix all reported issues - feed, follows, comments, reposts, notifications, privacy, DMs, avatars (#37)
* fix(birdy): reconnect the feed, follow lists, comments and join date (#15) Six of the reported birdy bugs share one theme: the server work was already done and the UI simply never reached it. This reconnects those paths rather than building anything new. Following tab was permanently empty Feed.tsx branched on `feed === 'following'` and rendered a hardcoded empty state that never consulted `posts`, so correctly-fetched posts were thrown away. store.listFeed already implements the follows join, and Birdy.tsx already requests it. Collapsed to a single length check with a tab-dependent message. The dev stub returned [] for the following tab too, which is presumably how the placeholder survived. Followers / Following lists always blank apiFollowList calls 'sd-phone:birdy:followList', which was never proxied in client/apps/birdy.lua nor registered in server/birdy/init.lua - and no store/action existed. The screen was fully built against a callback that did not exist. Added store.followList (both directions, with the reciprocal follows_you / is_following flags resolved in SQL), actions.followList, and both registrations. Modelled on photogram, which has this same chain. Comment button did nothing The comment ActionButton was rendered with no onClick, and ActionButton stopPropagation()s - so it also swallowed the card's own open handler, making it the one spot on a post guaranteed to do nothing. It now opens the post, where the reply composer already lives. Tapping a commenter's avatar/name did nothing The profile SlideOver was gated on `!openPostId`. Reaching comments always means a post is open, so that gate suppressed it 100% of the time from comments (it worked from the feed, where no post is open). Dropped that clause; the SlideOver is z-30 over the post's z-20, so it layers correctly and closing it returns to the comments. New posts invisible until you switched tabs actions.create only fired TriggerEvent, which is server-LOCAL (a hook for third-party scripts) and reaches no players. There was no broadcast, no polling, and no foreground refetch - and because AppDeck retains the app, closing and reopening does not remount it, so nothing re-ran. Added a TriggerClientEvent broadcast, a client relay, and a nonce the app bumps on that push and on background->foreground (transition only, so opening the app does not double-fetch). Join date freely editable join_label was a client-writable string that updateProfile accepted verbatim, so anyone could set "January 1889". The real created_at column existed but was never selected. It is now read back, the label is derived from it in serializeProfile, updateProfile ignores the field, and the editor shows it as read-only text. Legacy rows fall back to the stored label. Verified: tsc, eslint (0 errors) and vitest all clean. Not exercised against a live server - no FiveM here - so the Lua paths are reasoned through rather than run. Worth a smoke test of the follow lists and the cross-player feed push before merge. Still outstanding on #15, all genuinely unbuilt rather than miswired, and each deserving its own PR: reposts (no table/action at all - the count is hardcoded 0 server-side and the button only flips local state), private accounts (the flag round-trips but no query or UI reads it), notification badges (no seen/read column exists), post fan-out to followers, DM compose (blocked by there being no handle->citizenid resolver), and avatar/banner uploads (no columns, buttons are decorative). * feat(birdy): persist reposts (#15) Reposts were entirely client-side: no table, no action, no callback, and serializePost pinned `reposts = 0` for every post. PostCard flipped a local useState, so the count never moved for anyone else and the repost vanished on the next refetch. - phone_birdy_reposts, mirroring phone_birdy_likes (composite PK = idempotent). - store add/remove/isReposted, plus repost_count and a viewer `reposted` flag in POST_SELECT. That makes the viewer cid params #1 AND #2, so all seven POST_SELECT call sites now pass it twice. - actions.toggleRepost on the toggleLike model, notifying the post author on a new repost (never on undo, never for self-reposts), registered through withNotifyPush and proxied on the client. - A 'repost' notification kind, rendered with the Repeat2 glyph. - PostCard reads server truth; Birdy.tsx owns the optimistic flip like it already does for likes. - deleteAccount now clears reposts, both the account's own and others' on its posts. Typecheck and lint clean. Not exercised against a live server. * feat(birdy): notification badges, post fan-out, private accounts, DM compose, avatars (#15) Completes the remaining #15 items, all previously unbuilt. Notification badges phone_birdy_notifications gains a `seen` column (historic rows backfilled as seen so nobody logs in to a giant badge) plus an unseen index. Opening the Bell tab marks everything seen; a notificationCount callback feeds the in-app Bell badge, birdy is registered in the springboard badge snapshot, and SERVER_BADGE_APPS gains 'birdy' so the server owns the app-icon count. The web app finally listens to the existing notification push instead of letting it fire into the void. Post fan-out Posting now writes a 'post' notification for every follower and, for the ones online, sends the app nudge, a phone banner, and a badge push - birdy previously never sent a phone banner for anything. Private accounts (the toggle now does what it says) The All feed hides protected authors from non-followers in SQL, profile post tabs return empty + protected for non-followers, follow lists of a protected account are hidden, and the profile page shows a lock state. Followers and the account itself see everything as before. DM compose dmSend accepts a handle (resolved server-side) and rejects self-DMs; a new dmResolve callback maps handle -> conversation id so the UI can open a thread with someone never messaged. Messages gains a compose button with a people search, and other profiles gain a Message button. Avatars and banners avatar/banner VARCHAR(512) columns, validated with the photogram idiom (http prefix, 512 cap, false clears). serializeAuthor/serializeProfile and every profile-card query now carry them; the previously-dead camera buttons in Edit Profile open the shared MediaPickerSheet; the Avatar component (which already took src) now receives it across the app. Typecheck, lint, tests all clean. Not exercised against a live server. --------- Co-authored-by: Samuel Nicol <99494967+Github-Samuel@users.noreply.github.com>
1 parent 0897788 commit dc2e44d

19 files changed

Lines changed: 555 additions & 100 deletions

File tree

‎client/apps/birdy.lua‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ proxyCallback('sd-phone:birdy:create', 'sd-phone:server:birdy:create')
1919
proxyCallback('sd-phone:birdy:reply', 'sd-phone:server:birdy:reply')
2020
proxyCallback('sd-phone:birdy:toggleLike', 'sd-phone:server:birdy:toggleLike')
2121
proxyCallback('sd-phone:birdy:toggleFollow', 'sd-phone:server:birdy:toggleFollow')
22+
proxyCallback('sd-phone:birdy:toggleRepost', 'sd-phone:server:birdy:toggleRepost')
23+
proxyCallback('sd-phone:birdy:followList', 'sd-phone:server:birdy:followList')
2224
proxyCallback('sd-phone:birdy:notifications', 'sd-phone:server:birdy:notifications')
25+
proxyCallback('sd-phone:birdy:notificationCount', 'sd-phone:server:birdy:notificationCount')
26+
proxyCallback('sd-phone:birdy:dmResolve', 'sd-phone:server:birdy:dmResolve')
2327
proxyCallback('sd-phone:birdy:dmList', 'sd-phone:server:birdy:dmList')
2428
proxyCallback('sd-phone:birdy:dmThread', 'sd-phone:server:birdy:dmThread')
2529
proxyCallback('sd-phone:birdy:dmSend', 'sd-phone:server:birdy:dmSend')
@@ -42,3 +46,9 @@ end)
4246
RegisterNetEvent('sd-phone:client:birdy:notification', function(data)
4347
SendNUIMessage({ action = 'sd-phone:birdy:notification', data = data })
4448
end)
49+
50+
---Server push: somebody posted, so any open feed is now stale.
51+
---@param data table empty payload from server/birdy/actions.lua
52+
RegisterNetEvent('sd-phone:client:birdy:feedChanged', function(data)
53+
SendNUIMessage({ action = 'sd-phone:birdy:feedChanged', data = data or {} })
54+
end)

‎server/badges/init.lua‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ local groupStore = require 'server.groups.store'
1212
local acctStore = require 'server.accounts.store'
1313
---@type table Photogram persistence layer (server.photogram.store): notification/DM counts.
1414
local photogramStore = require 'server.photogram.store'
15+
---@type table Birdy persistence layer (server.birdy.store): unseen-notification counts.
16+
local birdyStore = require 'server.birdy.store'
1517

1618
---@type table Badges module; the table returned at end of file.
1719
local badges = {}
@@ -29,14 +31,15 @@ end
2931
---Per-app unread counts for one character, keyed by home-screen app id, computed straight from
3032
---the database on every call.
3133
---@param cid string framework per-character id
32-
---@return { messages: number, phone: number, mail: number, groups: number, photogram: number }
34+
---@return { messages: number, phone: number, mail: number, groups: number, photogram: number, birdy: number }
3335
function badges.snapshot(cid)
3436
return {
3537
messages = messageStore.unreadCount(cid),
3638
phone = contactStore.unreadMissedCount(cid),
3739
mail = mailStore.unreadCount(cid),
3840
groups = groupStore.pendingInviteCount(cid),
3941
photogram = photogramCount(cid),
42+
birdy = birdyStore.unseenNotificationCount(cid),
4043
}
4144
end
4245

@@ -54,7 +57,7 @@ end
5457
---Read-only.
5558
lib.callback.register('sd-phone:server:badges:get', function(src)
5659
local cid = player.getIdentifier(src)
57-
if not cid then return { messages = 0, phone = 0, mail = 0, groups = 0, photogram = 0 } end
60+
if not cid then return { messages = 0, phone = 0, mail = 0, groups = 0, photogram = 0, birdy = 0 } end
5861
return badges.snapshot(cid)
5962
end)
6063

@@ -69,7 +72,7 @@ end)
6972
---A player's current per-app unread counts without pushing them. Nil when the source doesn't
7073
---resolve to a loaded character.
7174
---@param source number player server id
72-
---@return { messages: number, phone: number, mail: number, groups: number, photogram: number }|nil counts
75+
---@return { messages: number, phone: number, mail: number, groups: number, photogram: number, birdy: number }|nil counts
7376
exports('getBadgeCounts', function(source)
7477
if type(source) ~= 'number' then return nil end
7578
local cid = player.getIdentifier(source)

‎server/birdy/actions.lua‎

Lines changed: 157 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ local acctActions = require 'server.accounts.actions'
1212
local settings = require 'server.settings.store'
1313
---@type table Banking actions (server.banking.actions): authoritative money transfer for money DMs.
1414
local banking = require 'server.banking.actions'
15+
---@type table Badges module (server.badges.init): server-authoritative unread-badge pushes.
16+
local badges = require 'server.badges.init'
1517
---@type table Admin mute registry (server.admin.moderation): scope guards for posting/DMing.
1618
local moderation = require 'server.admin.moderation'
1719

@@ -114,9 +116,9 @@ end
114116

115117
---Public author shape embedded in posts, notifications and conversation heads.
116118
---@param profile table
117-
---@return { name: string, handle: string, verified: boolean }
119+
---@return { name: string, handle: string, verified: boolean, avatar?: string }
118120
local function serializeAuthor(profile)
119-
return { name = profile.displayName, handle = profile.handle, verified = profile.verified }
121+
return { name = profile.displayName, handle = profile.handle, verified = profile.verified, avatar = profile.avatar }
120122
end
121123

122124
---Shapes a full profile (with live follow counts) for the profile page.
@@ -128,26 +130,30 @@ local function serializeProfile(profile)
128130
handle = profile.handle,
129131
verified = profile.verified,
130132
bio = profile.bio or '',
131-
joined = profile.joinLabel or '',
133+
-- Derived from created_at; join_label was client-writable.
134+
joined = profile.createdTs and os.date('%B %Y', profile.createdTs) or (profile.joinLabel or ''),
132135
protected = profile.protected == true,
136+
avatar = profile.avatar,
137+
banner = profile.banner,
133138
following = store.countFollowing(profile.citizenid),
134139
followers = store.countFollowers(profile.citizenid),
135140
}
136141
end
137142

138143
---Shapes a hydrated post row into the React `BirdyPost` form. `images` is nil or the
139-
---store-decoded array of up to 3 URLs; the repost count is pinned at 0.
144+
---store-decoded array of up to 3 URLs.
140145
---@param p table
141146
---@return table
142147
local function serializePost(p)
143148
return {
144149
id = p.id,
145-
author = { name = p.displayName, handle = p.handle, verified = p.verified },
150+
author = { name = p.displayName, handle = p.handle, verified = p.verified, avatar = p.avatar },
146151
body = p.body,
147152
images = p.images,
148153
createdAt = p.createdMs,
149154
replies = p.replies,
150-
reposts = 0,
155+
reposts = p.reposts,
156+
reposted = p.reposted,
151157
likes = p.likes,
152158
liked = p.liked,
153159
views = p.views,
@@ -313,6 +319,14 @@ function actions.profilePosts(source, payload)
313319
if not targetCid then return fail('Profile not found') end
314320
local kind = (payload and payload.kind) or 'posts'
315321

322+
-- Protected profiles expose posts only to themselves and their followers.
323+
if targetCid ~= viewerCid then
324+
local tp = store.getProfile(targetCid)
325+
if tp and tp.protected and not (viewerCid ~= '' and store.isFollowing(viewerCid, targetCid)) then
326+
return ok({ posts = {}, protected = true })
327+
end
328+
end
329+
316330
local rows
317331
if kind == 'likes' then
318332
rows = store.listLikedBy(targetCid, viewerCid, birdyCfg.FeedLimit)
@@ -346,7 +360,7 @@ end
346360
---Updates the signed-in user's editable profile fields. Missing fields keep their current
347361
---value; everything is trimmed and bounds-checked.
348362
---@param source number player server id
349-
---@param payload { name?: string, bio?: string, joinLabel?: string, protected?: boolean }|nil
363+
---@param payload { name?: string, bio?: string, protected?: boolean, avatar?: string|false, banner?: string|false }|nil
350364
---@return table envelope
351365
function actions.updateProfile(source, payload)
352366
local prof = viewer(source); if not prof then return fail('Not signed in') end
@@ -359,9 +373,17 @@ function actions.updateProfile(source, payload)
359373
local bio = trimmed(payload.bio) or ''
360374
if #bio > birdyCfg.MaxBioLength then return fail('Bio is too long') end
361375

362-
local joinLabel = (trimmed(payload.joinLabel) or prof.joinLabel or ''):sub(1, 32)
376+
local function imageUrl(v, fallback)
377+
local u = trimmed(v)
378+
if u and u:sub(1, 4) == 'http' then return u:sub(1, 512) end
379+
if v == false then return nil end
380+
return fallback
381+
end
382+
local avatar = imageUrl(payload.avatar, prof.avatar)
383+
local banner = imageUrl(payload.banner, prof.banner)
363384

364-
store.updateProfileFields(prof.citizenid, name, bio, joinLabel, payload.protected == true)
385+
-- joinLabel is ignored; the join date is derived from created_at.
386+
store.updateProfileFields(prof.citizenid, name, bio, prof.joinLabel or '', payload.protected == true, avatar, banner)
365387
return ok({ profile = serializeProfile(store.getProfile(prof.citizenid)) })
366388
end
367389

@@ -462,6 +484,25 @@ function actions.create(source, payload)
462484
username = prof.handle, displayName = prof.displayName,
463485
body = body, images = images,
464486
})
487+
488+
-- The TriggerEvent above is server-local; this is what reaches players.
489+
TriggerClientEvent('sd-phone:client:birdy:feedChanged', -1, {})
490+
491+
local preview = body ~= '' and body:sub(1, 80) or 'shared a photo'
492+
for _, cid in ipairs(store.followerCids(prof.citizenid)) do
493+
store.insertNotification(store.newId(), cid, 'post', prof.citizenid, id)
494+
local src = player.getSourceByIdentifier(cid)
495+
if src then
496+
TriggerClientEvent('sd-phone:client:birdy:notification', src, {})
497+
TriggerClientEvent('sd-phone:client:notify', src, {
498+
app = 'birdy', appId = 'birdy', title = 'Birdy',
499+
body = ('%s posted: %s'):format(prof.displayName, preview),
500+
time = 'now', quietInApp = true,
501+
})
502+
badges.push(src)
503+
end
504+
end
505+
465506
return ok({ post = serializePost(store.getPost(id, prof.citizenid)) })
466507
end
467508

@@ -527,6 +568,77 @@ function actions.toggleLike(source, payload)
527568
return ok({ liked = nowLiked, notifyCid = notifyCid })
528569
end
529570

571+
---Toggles a repost of a post. Mirrors toggleLike: idempotent per (post, citizen), and notifies
572+
---the post's author on a new repost (never on un-repost, never for self-reposts).
573+
---@param source number player server id
574+
---@param payload { id?: string }|nil
575+
---@return table envelope
576+
function actions.toggleRepost(source, payload)
577+
local prof = viewer(source); if not prof then return fail('Player not found') end
578+
payload = tbl(payload)
579+
local id = payload and payload.id
580+
if type(id) ~= 'string' or id == '' then return fail('Missing post') end
581+
582+
local author = store.getPostAuthor(id)
583+
if not author then return fail('Post not found') end
584+
585+
local nowReposted
586+
if store.isReposted(id, prof.citizenid) then
587+
store.removeRepost(id, prof.citizenid)
588+
nowReposted = false
589+
else
590+
store.addRepost(id, prof.citizenid)
591+
nowReposted = true
592+
end
593+
594+
local notifyCid = nil
595+
if nowReposted and author ~= prof.citizenid then
596+
store.insertNotification(store.newId(), author, 'repost', prof.citizenid, id)
597+
notifyCid = author
598+
end
599+
600+
return ok({ reposted = nowReposted, notifyCid = notifyCid })
601+
end
602+
603+
---Followers or following for a handle (defaulting to the viewer's own profile), shaped for the
604+
---FollowList screen. Read-only.
605+
---@param source number player server id
606+
---@param payload { kind?: 'followers'|'following', handle?: string }|nil
607+
---@return table envelope
608+
function actions.followList(source, payload)
609+
local prof = viewer(source); if not prof then return fail('Player not found') end
610+
payload = tbl(payload)
611+
612+
local kind = payload.kind == 'following' and 'following' or 'followers'
613+
614+
-- No handle means own list; an unknown handle is empty, not an error.
615+
local targetCid = prof.citizenid
616+
local handle = payload.handle and normalizeHandle(payload.handle)
617+
if handle and handle ~= '' and handle ~= prof.handle then
618+
local tp = store.getProfileByHandle(handle)
619+
if not tp then return ok({ users = {} }) end
620+
-- Protected profiles hide their follow graph from non-followers.
621+
if tp.protected and not store.isFollowing(prof.citizenid, tp.citizenid) then
622+
return ok({ users = {} })
623+
end
624+
targetCid = tp.citizenid
625+
end
626+
627+
local users = {}
628+
for _, row in ipairs(store.followList(prof.citizenid, targetCid, kind)) do
629+
users[#users + 1] = {
630+
name = row.display_name,
631+
handle = row.handle,
632+
verified = tonumber(row.verified) == 1,
633+
bio = row.bio or '',
634+
avatar = row.avatar,
635+
followsYou = tonumber(row.follows_you) == 1,
636+
isFollowing = tonumber(row.is_following) == 1,
637+
}
638+
end
639+
return ok({ users = users })
640+
end
641+
530642
---Toggles following another account, addressed by handle (preferred) or citizenid. Self-follows
531643
---are rejected. Returns the target to notify on a new follow (not on unfollow).
532644
---@param source number player server id
@@ -590,15 +702,31 @@ function actions.notifications(source)
590702
local user = ap and serializeAuthor(ap) or { name = 'Someone', handle = 'someone', verified = false }
591703
if r.kind == 'like' then
592704
items[#items + 1] = { id = r.id, kind = 'like', user = user, text = 'liked your post' }
705+
elseif r.kind == 'repost' then
706+
items[#items + 1] = { id = r.id, kind = 'repost', user = user, text = 'reposted your post' }
707+
elseif r.kind == 'post' then
708+
items[#items + 1] = { id = r.id, kind = 'post', user = user, text = 'shared a new post' }
593709
elseif r.kind == 'follow' then
594710
items[#items + 1] = { id = r.id, kind = 'follow', user = user }
595711
end
596712
end
597713
end
598714

715+
store.markNotificationsSeen(prof.citizenid)
716+
badges.push(source)
717+
599718
return ok({ notifications = items })
600719
end
601720

721+
---Unseen-notification count for the in-app Bell badge. Read-only.
722+
---@param source number player server id
723+
---@return table envelope
724+
function actions.notificationCount(source)
725+
local prof = viewer(source)
726+
if not prof then return ok({ count = 0 }) end
727+
return ok({ count = store.unseenNotificationCount(prof.citizenid) })
728+
end
729+
602730
-- Rich DM messages (text / image / gif / money / location / voice).
603731
---@type table<string, boolean> Whitelist of DM kinds a client may send; anything else sends as text.
604732
local VALID_DM_KINDS = { text = true, image = true, gif = true, money = true, location = true, voice = true }
@@ -770,6 +898,20 @@ function actions.markRead(source, payload)
770898
return ok()
771899
end
772900

901+
---Resolves a handle to its DM conversation id (the other party's cid) plus their author card,
902+
---so the UI can open a thread with someone it has never messaged. Read-only.
903+
---@param source number player server id
904+
---@param payload { handle?: string }|nil
905+
---@return table envelope
906+
function actions.dmResolve(source, payload)
907+
local prof = viewer(source); if not prof then return fail('Player not found') end
908+
payload = tbl(payload)
909+
local tp = store.getProfileByHandle(normalizeHandle(payload.handle or '') or '')
910+
if not tp then return fail('Account not found') end
911+
if tp.citizenid == prof.citizenid then return fail('You cannot message yourself') end
912+
return ok({ id = tp.citizenid, user = serializeAuthor(tp) })
913+
end
914+
773915
---Sends a DM of any kind. Returns the sender's own message + the recipient's copy + the routing
774916
---data init needs. Money clears through banking.send before the row is stored.
775917
---@param source number player server id
@@ -780,7 +922,13 @@ function actions.dmSend(source, payload)
780922
local muted = moderation.guard(prof.citizenid, 'birdy'); if muted then return muted end
781923
payload = tbl(payload)
782924
local toCid = payload.toCid
925+
-- Discovery surfaces only expose handles, so accept one and resolve it here.
926+
if (type(toCid) ~= 'string' or toCid == '') and payload.toHandle then
927+
local tp = store.getProfileByHandle(normalizeHandle(payload.toHandle) or '')
928+
toCid = tp and tp.citizenid
929+
end
783930
if type(toCid) ~= 'string' or toCid == '' or #toCid > 64 then return fail('Missing recipient') end
931+
if toCid == prof.citizenid then return fail('You cannot message yourself') end
784932

785933
local kind = VALID_DM_KINDS[payload.kind] and payload.kind or 'text'
786934
local body = (trimmed(payload.body) or ''):sub(1, birdyCfg.MaxDmLength)

‎server/birdy/init.lua‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,11 @@ lib.callback.register('sd-phone:server:birdy:create', function(src, payl
5353
lib.callback.register('sd-phone:server:birdy:reply', function(src, payload) return withNotifyPush(actions.reply(src, payload)) end)
5454
lib.callback.register('sd-phone:server:birdy:toggleLike', function(src, payload) return withNotifyPush(actions.toggleLike(src, payload)) end)
5555
lib.callback.register('sd-phone:server:birdy:toggleFollow', function(src, payload) return withNotifyPush(actions.toggleFollow(src, payload)) end)
56+
lib.callback.register('sd-phone:server:birdy:toggleRepost', function(src, payload) return withNotifyPush(actions.toggleRepost(src, payload)) end)
57+
lib.callback.register('sd-phone:server:birdy:followList', function(src, payload) return actions.followList(src, payload) end)
5658
lib.callback.register('sd-phone:server:birdy:notifications', function(src) return actions.notifications(src) end)
59+
lib.callback.register('sd-phone:server:birdy:notificationCount', function(src) return actions.notificationCount(src) end)
60+
lib.callback.register('sd-phone:server:birdy:dmResolve', function(src, payload) return actions.dmResolve(src, payload) end)
5761
lib.callback.register('sd-phone:server:birdy:dmList', function(src) return actions.dmList(src) end)
5862
lib.callback.register('sd-phone:server:birdy:dmThread', function(src, payload) return actions.dmThread(src, payload) end)
5963
lib.callback.register('sd-phone:server:birdy:dmMarkRead', function(src, payload) return actions.markRead(src, payload) end)

0 commit comments

Comments
 (0)