ENH - rebuild Picstagram with SKYUI

Replace the legacy Konsta-based layout at its source with SKYUI components and responsive app-specific styling. Add media creation, profile and social improvements, comment reactions and replies, plus Activity and phone notification delivery with the required database migration.
This commit is contained in:
Leon.Schmidt
2026-08-14 16:39:41 +02:00
parent e033271cb2
commit e81f092991
12 changed files with 2975 additions and 2053 deletions
+12 -2
View File
@@ -205,7 +205,15 @@ type PicstagramVerificationData = {
type PicstagramNotificationData = {
actor?: string
device?: PhoneNotificationDevicePayload
kind?: 'like' | 'comment' | 'follow' | 'follow_request' | 'verified'
kind?:
| 'like'
| 'comment'
| 'comment_like'
| 'reply'
| 'follow'
| 'follow_request'
| 'request_accepted'
| 'verified'
postId?: string
text?: string
title?: string
@@ -1411,7 +1419,9 @@ onBeforeUnmount(() => {
<Transition :name="appTransitionName">
<component
:is="Component"
v-if="!setupRequired && (isAppRoute || isDevelopmentRoute)"
v-if="
!setupRequired && (isAppRoute || isDevelopmentRoute)
"
:key="
isDevelopmentRoute ? String(route.name) : route.path
"
+28 -9
View File
@@ -988,8 +988,12 @@ const defaultLocales: LocaleTree = {
story: 'Story',
choosePhotos: 'Choose photos',
choosePhoto: 'Choose photo',
choosePhotosHint: 'Select up to five photos from Photos',
chooseStoryHint: 'Select one photo from Photos',
photo: 'Photo',
video: 'Video',
gallery: 'Gallery',
camera: 'Camera',
choosePhotosHint: 'Select up to five photos or one video',
chooseStoryHint: 'Select one photo or video',
selectedPhotos: '{count} selected',
changePhotos: 'Change selection',
caption: 'Caption',
@@ -1006,6 +1010,8 @@ const defaultLocales: LocaleTree = {
noComments: 'No comments yet',
addComment: 'Add a comment...',
reply: 'Reply',
replyPlaceholder: 'Write a reply...',
replyingTo: 'Replying to {handle}',
removeComment: 'Remove comment',
likes: '{count} likes',
viewComments: 'View all {count} comments',
@@ -1026,6 +1032,8 @@ const defaultLocales: LocaleTree = {
unfollow: 'Following',
accept: 'Accept',
decline: 'Decline',
noConnections: 'No profiles here yet',
shareProfile: 'Share profile',
followRequests: 'Follow requests',
noActivity: 'No activity yet',
markRead: 'Mark as read',
@@ -1079,6 +1087,8 @@ const defaultLocales: LocaleTree = {
request_accepted: 'accepted your follow request',
like: 'liked your post',
comment: 'commented on your post',
comment_like: 'liked your comment',
reply: 'replied to your comment',
verified: 'verification changed',
},
notifications: {
@@ -1087,6 +1097,8 @@ const defaultLocales: LocaleTree = {
request_accepted: '{actor} accepted your follow request.',
like: '{actor} liked your post.',
comment: '{actor} commented on your post.',
comment_like: '{actor} liked your comment.',
reply: '{actor} replied to your comment.',
verified: 'Your Picstagram profile is now verified.',
default: 'You have new Picstagram activity.',
},
@@ -3913,15 +3925,19 @@ const defaultLocales: LocaleTree = {
eraseLocalApps: 'Downloaded apps and local app sessions',
keptSafe: 'Kept Safe',
keepSimData: 'SIM Card & Phone Number',
keepSimDataBody: 'Your number and SIM-backed communication remain on the SIM.',
keepSimDataBody:
'Your number and SIM-backed communication remain on the SIM.',
keepCloudData: 'Account & Cloud Data',
keepCloudDataBody: 'Supported information remains in its app or Sky Cloud account.',
resetSetupAssistant: 'When erasing is complete, Setup Assistant starts automatically.',
keepCloudDataBody:
'Supported information remains in its app or Sky Cloud account.',
resetSetupAssistant:
'When erasing is complete, Setup Assistant starts automatically.',
factoryReset: 'Erase All Content and Settings',
factoryResetBody:
'This cannot be undone. Local settings, apps and unsynced content are erased. Your SIM number and account-backed data remain available.',
factoryResetProgress: 'Erasing Sky Phone',
factoryResetWarning: 'Keep this phone open. Your SIM and cloud data are safe.',
factoryResetWarning:
'Keep this phone open. Your SIM and cloud data are safe.',
factoryResetSystemProcess: 'Secure system process',
factoryResetPreparing: 'Preparing secure reset',
factoryResetPreparingDetail: 'Checking device data',
@@ -4084,8 +4100,10 @@ const defaultLocales: LocaleTree = {
create: 'Create Account',
signInTitle: 'Sign in to Sky Cloud',
createTitle: 'Create your Sky Cloud account',
signInBody: 'Access your protected data, purchases and settings on this phone.',
createBody: 'Choose your personal iFruit address and keep important data available across devices.',
signInBody:
'Access your protected data, purchases and settings on this phone.',
createBody:
'Choose your personal iFruit address and keep important data available across devices.',
accountPreview: 'Your Sky Cloud ID',
accountName: 'Account name',
addressPlaceholder: 'alex.morgan',
@@ -4116,7 +4134,8 @@ const defaultLocales: LocaleTree = {
eyebrow: 'Privacy & Security',
title: 'Protect this phone',
body: 'Choose a four- or six-digit passcode to keep your apps and local information private.',
local: 'The passcode belongs to this physical phone and is never transferred with the SIM.',
local:
'The passcode belongs to this physical phone and is never transferred with the SIM.',
create: 'Create Passcode',
createSelected: 'Create {count}-Digit Passcode',
lengthTitle: 'Choose passcode length',
+12 -1
View File
@@ -49,7 +49,14 @@ const post: PicstagramPost = {
is_saved: false,
like_count: 2,
location: 'Downtown',
media: [{ id: 1, position: 0, url: 'https://example.com/photo.webp' }],
media: [
{
id: 1,
media_type: 'photo',
position: 0,
url: 'https://example.com/photo.webp',
},
],
private: false,
profile_id: profile.id,
verified: false,
@@ -62,9 +69,12 @@ const comment: PicstagramComment = {
display_name: 'Nova',
handle: 'nova',
id: 'comment-1',
is_liked: false,
is_owner: true,
like_count: 0,
parent_id: null,
profile_id: profile.id,
reply_to_handle: null,
verified: false,
}
@@ -91,6 +101,7 @@ const story: PicstagramStory = {
handle: 'nova',
id: 'story-1',
is_owner: true,
media_type: 'photo',
profile_id: profile.id,
seen: true,
url: 'https://example.com/story.webp',
+29 -2
View File
@@ -29,6 +29,7 @@ export const usePicstagramStore = defineStore('picstagram', {
activities: [] as PicstagramActivity[],
authenticated: false,
comments: [] as PicstagramComment[],
connections: [] as PicstagramProfile[],
explore: [] as PicstagramPost[],
exploreCursor: null as string | null,
feed: [] as PicstagramPost[],
@@ -199,6 +200,7 @@ export const usePicstagramStore = defineStore('picstagram', {
commentsEnabled: boolean
location: string
mediaIds: number[]
mediaType: 'photo' | 'video'
}): Promise<NuiResponse<{ id: string }>> {
const response = await nuiCall<{ id: string }>(
'picstagram:publish-post',
@@ -283,8 +285,9 @@ export const usePicstagramStore = defineStore('picstagram', {
id: string,
body: string,
parentId?: string,
replyToId?: string,
): Promise<NuiResponse> {
return nuiCall('picstagram:comment', { body, id, parentId })
return nuiCall('picstagram:comment', { body, id, parentId, replyToId })
},
async removeComment(id: string): Promise<boolean> {
const response = await nuiCall('picstagram:remove-comment', { id })
@@ -292,6 +295,29 @@ export const usePicstagramStore = defineStore('picstagram', {
this.comments = this.comments.filter((comment) => comment.id !== id)
return response.success
},
async reactComment(comment: PicstagramComment): Promise<void> {
const active = !comment.is_liked
comment.is_liked = active
comment.like_count += active ? 1 : -1
const response = await nuiCall('picstagram:comment-react', {
active,
id: comment.id,
})
if (response.success) return
comment.is_liked = !active
comment.like_count += active ? -1 : 1
},
async loadConnections(
profileId: string,
mode: 'followers' | 'following',
): Promise<boolean> {
const response = await nuiCall<PicstagramProfile[]>(
'picstagram:connections',
{ mode, profileId },
)
this.connections = response.success && response.data ? response.data : []
return response.success
},
async loadStories(): Promise<boolean> {
const response = await nuiCall<PicstagramStory[]>('picstagram:stories')
this.stories = response.success && response.data ? response.data : []
@@ -300,10 +326,11 @@ export const usePicstagramStore = defineStore('picstagram', {
async publishStory(
mediaId: number,
body: string,
mediaType: 'photo' | 'video',
): Promise<NuiResponse<{ id: string }>> {
const response = await nuiCall<{ id: string }>(
'picstagram:publish-story',
{ body, mediaId },
{ body, mediaId, mediaType },
)
if (response.success) await this.loadStories()
return response
+7
View File
@@ -20,6 +20,7 @@ export type PicstagramProfile = {
export type PicstagramPostMedia = {
id: number
media_type: 'photo' | 'video'
position: number
url: string
}
@@ -67,9 +68,12 @@ export type PicstagramComment = {
display_name: string
handle: string
id: string
is_liked: boolean
is_owner: boolean
like_count: number
parent_id: string | null
profile_id: string
reply_to_handle: string | null
verified: boolean
}
@@ -82,6 +86,7 @@ export type PicstagramStory = {
handle: string
id: string
is_owner: boolean
media_type: 'photo' | 'video'
profile_id: string
seen: boolean
url: string
@@ -104,6 +109,8 @@ export type PicstagramActivityKind =
| 'request_accepted'
| 'like'
| 'comment'
| 'comment_like'
| 'reply'
| 'verified'
export type PicstagramActivity = {
File diff suppressed because it is too large Load Diff
+69 -6
View File
@@ -716,11 +716,13 @@ let picstagramPosts = [
media: [
{
id: 8101,
media_type: 'photo',
position: 0,
url: 'https://images.unsplash.com/photo-1518005020951-eccb494ad742?auto=format&fit=crop&w=900&q=85',
},
{
id: 8102,
media_type: 'photo',
position: 1,
url: 'https://images.unsplash.com/photo-1444723121867-7a241cacace9?auto=format&fit=crop&w=900&q=85',
},
@@ -746,6 +748,7 @@ let picstagramPosts = [
media: [
{
id: 8103,
media_type: 'photo',
position: 0,
url: 'https://images.unsplash.com/photo-1519608487953-e999c86e7455?auto=format&fit=crop&w=900&q=85',
},
@@ -763,9 +766,12 @@ let picstagramComments = [
display_name: picstagramProfiles[1].display_name,
handle: picstagramProfiles[1].handle,
id: 'pic-comment-1',
is_liked: false,
is_owner: false,
like_count: 4,
parent_id: null,
profile_id: picstagramProfiles[1].id,
reply_to_handle: null,
verified: true,
},
]
@@ -779,6 +785,7 @@ let picstagramStories = [
handle: picstagramProfiles[1].handle,
id: 'pic-story-1',
is_owner: false,
media_type: 'photo',
profile_id: picstagramProfiles[1].id,
seen: false,
url: 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=900&q=85',
@@ -2490,7 +2497,12 @@ let mockMedia = [
favorite: true,
id: 2,
mediaType: 'video',
thumbnailUrl: mockGalleryImage('Flower Video', '#7f1d1d', '#365314', '#fb7185'),
thumbnailUrl: mockGalleryImage(
'Flower Video',
'#7f1d1d',
'#365314',
'#fb7185',
),
url: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
},
{
@@ -2519,7 +2531,12 @@ let mockMedia = [
favorite: false,
id: 6,
mediaType: 'video',
thumbnailUrl: mockGalleryImage('Sintel Video', '#9a3412', '#431407', '#fdba74'),
thumbnailUrl: mockGalleryImage(
'Sintel Video',
'#9a3412',
'#431407',
'#fdba74',
),
url: 'https://media.w3.org/2010/05/sintel/trailer.mp4',
},
{
@@ -2548,7 +2565,12 @@ let mockMedia = [
favorite: false,
id: 10,
mediaType: 'video',
thumbnailUrl: mockGalleryImage('Bunny Video', '#166534', '#14532d', '#86efac'),
thumbnailUrl: mockGalleryImage(
'Bunny Video',
'#166534',
'#14532d',
'#86efac',
),
url: 'https://www.w3schools.com/html/mov_bbb.mp4',
},
{
@@ -6308,6 +6330,14 @@ app.post('/api/:endpoint', (request, response) => {
})
return
}
if (endpoint === 'picstagram:connections') {
const targetId = request.body.profileId
const profiles = picstagramProfiles.filter(
(profile) => profile.id !== targetId,
)
response.json({ success: true, data: profiles })
return
}
if (endpoint === 'picstagram:update-profile') {
Object.assign(picstagramProfiles[0], {
bio: request.body.bio,
@@ -6321,7 +6351,7 @@ app.post('/api/:endpoint', (request, response) => {
if (endpoint === 'picstagram:publish-post') {
const media = request.body.mediaIds
.map((id) => mockMedia.find((item) => item.id === id))
.filter((item) => item?.mediaType === 'photo')
.filter((item) => item?.mediaType === request.body.mediaType)
if (!media.length) {
response.json({ success: false, error: 'invalid_media' })
return
@@ -6342,6 +6372,7 @@ app.post('/api/:endpoint', (request, response) => {
location: request.body.location,
media: media.map((item, position) => ({
id: item.id,
media_type: item.mediaType,
position,
url: item.url,
})),
@@ -6423,6 +6454,9 @@ app.post('/api/:endpoint', (request, response) => {
return
}
if (endpoint === 'picstagram:comment') {
const replyTarget = request.body.replyToId
? picstagramComments.find((entry) => entry.id === request.body.replyToId)
: null
const comment = {
avatar_url: picstagramProfiles[0].avatar_url,
body: request.body.body,
@@ -6430,15 +6464,43 @@ app.post('/api/:endpoint', (request, response) => {
display_name: picstagramProfiles[0].display_name,
handle: picstagramProfiles[0].handle,
id: `pic-comment-${Date.now()}`,
is_liked: false,
is_owner: true,
parent_id: request.body.parentId ?? null,
like_count: 0,
parent_id:
replyTarget?.parent_id ??
replyTarget?.id ??
request.body.parentId ??
null,
profile_id: picstagramProfiles[0].id,
reply_to_handle: replyTarget
? replyTarget.handle
: request.body.parentId
? (picstagramComments.find(
(entry) => entry.id === request.body.parentId,
)?.handle ?? null)
: null,
verified: picstagramProfiles[0].verified,
}
picstagramComments.push(comment)
response.json({ success: true, data: { id: comment.id } })
return
}
if (endpoint === 'picstagram:comment-react') {
const comment = picstagramComments.find(
(item) => item.id === request.body.id,
)
if (comment) {
const changed = comment.is_liked !== request.body.active
comment.is_liked = request.body.active
if (changed) comment.like_count += request.body.active ? 1 : -1
}
response.json({
success: Boolean(comment),
error: comment ? undefined : 'invalid_comment',
})
return
}
if (endpoint === 'picstagram:remove-comment') {
picstagramComments = picstagramComments.filter(
(comment) => comment.id !== request.body.id,
@@ -6452,7 +6514,7 @@ app.post('/api/:endpoint', (request, response) => {
}
if (endpoint === 'picstagram:publish-story') {
const media = mockMedia.find((item) => item.id === request.body.mediaId)
if (!media || media.mediaType !== 'photo') {
if (!media || media.mediaType !== request.body.mediaType) {
response.json({ success: false, error: 'invalid_media' })
return
}
@@ -6465,6 +6527,7 @@ app.post('/api/:endpoint', (request, response) => {
handle: picstagramProfiles[0].handle,
id: `pic-story-${Date.now()}`,
is_owner: true,
media_type: media.mediaType,
profile_id: picstagramProfiles[0].id,
seen: true,
url: media.url,
+6 -6
View File
@@ -415,20 +415,20 @@ Locales["en"] = {
discoverPeople = "Discover people", searchPlaceholder = "Search profiles, captions, places, or tags",
noResults = "No results", noResultsBody = "Try another username, caption, place, or hashtag.",
newPost = "New Post", newStory = "New Story", post = "Post", story = "Story",
choosePhotos = "Choose photos", choosePhoto = "Choose photo",
choosePhotosHint = "Select up to five photos from Photos", chooseStoryHint = "Select one photo from Photos",
choosePhotos = "Choose photos", choosePhoto = "Choose photo", photo = "Photo", video = "Video", gallery = "Gallery", camera = "Camera",
choosePhotosHint = "Select up to five photos or one video", chooseStoryHint = "Select one photo or video",
selectedPhotos = "{count} selected", changePhotos = "Change selection", caption = "Caption",
captionPlaceholder = "Write a caption...", storyTextPlaceholder = "Add a short story text...",
location = "Location", locationPlaceholder = "Add a place", allowComments = "Allow comments",
publishing = "Publishing...", published = "Your post is live.", storyPublished = "Your story is live.",
comments = "Comments", comment = "Comment", noComments = "No comments yet", addComment = "Add a comment...",
reply = "Reply", removeComment = "Remove comment", likes = "{count} likes",
reply = "Reply", replyPlaceholder = "Write a reply...", replyingTo = "Replying to {handle}", removeComment = "Remove comment", likes = "{count} likes",
viewComments = "View all {count} comments", like = "Like", unlike = "Unlike", save = "Save",
unsave = "Remove from saved", more = "More", archive = "Archive", restore = "Restore",
deletePost = "Delete post", deletePostTitle = "Delete this post?",
deletePostBody = "The post and its reactions will no longer be available.",
privateProfile = "This profile is private", privateProfileBody = "Follow this profile to see its posts and stories.",
follow = "Follow", requested = "Requested", unfollow = "Following", accept = "Accept", decline = "Decline",
follow = "Follow", requested = "Requested", unfollow = "Following", accept = "Accept", decline = "Decline", noConnections = "No profiles here yet", shareProfile = "Share profile",
followRequests = "Follow requests", noActivity = "No activity yet", markRead = "Mark as read",
publicProfile = "Public profile", privateProfileSetting = "Private profile",
privacyHint = "Only accepted followers can see your posts and stories.", bio = "Bio",
@@ -442,8 +442,8 @@ Locales["en"] = {
noDetails = "No additional details", hide = "Hide", remove = "Remove", restoreAction = "Restore", dismiss = "Dismiss",
reportTargets = { post = "Post", profile = "Profile", comment = "Comment", story = "Story" },
reportReasons = { spam = "Spam or misleading", harassment = "Harassment or bullying", dangerous = "Dangerous activity", illegal = "Illegal content", other = "Something else" },
activityKinds = { follow_request = "requested to follow you", follow = "started following you", request_accepted = "accepted your follow request", like = "liked your post", comment = "commented on your post", verified = "verification changed" },
notifications = { follow_request = "{actor} requested to follow you.", follow = "{actor} started following you.", request_accepted = "{actor} accepted your follow request.", like = "{actor} liked your post.", comment = "{actor} commented on your post.", verified = "Your Picstagram profile is now verified.", default = "You have new Picstagram activity." },
activityKinds = { follow_request = "requested to follow you", follow = "started following you", request_accepted = "accepted your follow request", like = "liked your post", comment = "commented on your post", comment_like = "liked your comment", reply = "replied to your comment", verified = "verification changed" },
notifications = { follow_request = "{actor} requested to follow you.", follow = "{actor} started following you.", request_accepted = "{actor} accepted your follow request.", like = "{actor} liked your post.", comment = "{actor} commented on your post.", comment_like = "{actor} liked your comment.", reply = "{actor} replied to your comment.", verified = "Your Picstagram profile is now verified.", default = "You have new Picstagram activity." },
errors = {
invalid_post = "Check the post details.", invalid_story = "Check the story details.",
invalid_media = "Choose photos owned by this phone.", invalid_comment = "Enter a valid comment.",
+2
View File
@@ -120,6 +120,7 @@ local server_callbacks = {
"picstagram:search",
"picstagram:saved",
"picstagram:profile",
"picstagram:connections",
"picstagram:update-profile",
"picstagram:publish-post",
"picstagram:update-post",
@@ -129,6 +130,7 @@ local server_callbacks = {
"picstagram:respond-follow",
"picstagram:comments",
"picstagram:comment",
"picstagram:comment-react",
"picstagram:remove-comment",
"picstagram:publish-story",
"picstagram:stories",
+23 -1
View File
@@ -1399,6 +1399,24 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_picstagram_comment_reactions",
columns = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{ name = "comment_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_picstagram_comment_reaction", columns = "(`comment_id`, `profile_id`)" },
},
foreignKeys = {
{ column = "comment_id", references = "`sky_phone_picstagram_comments` (`id`) ON DELETE CASCADE" },
{ column = "profile_id", references = "`sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_picstagram_stories",
columns = {
@@ -1446,7 +1464,7 @@ local schema = {
{ name = "recipient_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "actor_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "post_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "kind", type = "ENUM('follow_request', 'follow', 'request_accepted', 'like', 'comment', 'verified') NOT NULL" },
{ name = "kind", type = "ENUM('follow_request', 'follow', 'request_accepted', 'like', 'comment', 'comment_like', 'reply', 'verified') NOT NULL" },
{ name = "read_at", type = "DATETIME NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
@@ -2621,6 +2639,10 @@ Bridge.Database.Query([[
ALTER TABLE `sky_phone_feather_notifications`
MODIFY COLUMN `kind` ENUM('like', 'reply', 'follow', 'quote') NOT NULL
]], {})
Bridge.Database.Query([[
ALTER TABLE `sky_phone_picstagram_activities`
MODIFY COLUMN `kind` ENUM('follow_request', 'follow', 'request_accepted', 'like', 'comment', 'comment_like', 'reply', 'verified') NOT NULL
]], {})
Bridge.Database.Query([[
ALTER TABLE `sky_phone_flare_swipes`
MODIFY COLUMN `choice` ENUM('like', 'pass', 'superlike') NOT NULL
+134 -20
View File
@@ -154,7 +154,7 @@ local function load_post_media(posts)
by_id[post.id] = post
end
local rows = Bridge.Database.Query(([[
SELECT pm.`post_id`, pm.`position`, m.`id`, m.`url`
SELECT pm.`post_id`, pm.`position`, m.`id`, m.`url`, m.`media_type`
FROM `sky_phone_picstagram_post_media` pm
JOIN `sky_phone_media` m ON m.`id` = pm.`media_id`
WHERE pm.`post_id` IN (%s)
@@ -166,6 +166,7 @@ local function load_post_media(posts)
if post then
post.media[#post.media + 1] = {
id = tonumber(media.id),
media_type = media.media_type,
position = tonumber(media.position),
url = media.url,
}
@@ -599,6 +600,48 @@ Bridge.Callbacks.Register("sky_phone:picstagram:profile", function(source, data)
return { success = true, data = { profile = target, posts = posts } }
end)
Bridge.Callbacks.Register("sky_phone:picstagram:connections", function(source, data)
local viewer, error_response = profile_for_session(source)
if not viewer then
return error_response
end
local target_id = type(data) == "table" and data.profileId or nil
local mode = type(data) == "table" and data.mode or nil
if not valid_id(target_id) or (mode ~= "followers" and mode ~= "following") then
return { success = false, error = "invalid_request" }
end
if target_id ~= viewer.id and are_profiles_blocked(viewer.id, target_id) then
return { success = false, error = "profile_not_found" }
end
local join_column = mode == "followers" and "follow.`follower_id`" or "follow.`following_id`"
local filter_column = mode == "followers" and "follow.`following_id`" or "follow.`follower_id`"
local rows = Bridge.Database.Query(([[
SELECT candidate.`id`, candidate.`handle`, candidate.`display_name`, candidate.`bio`,
candidate.`avatar_media_id`, candidate.`private`, candidate.`verified`, candidate.`status`,
avatar.`url` AS `avatar_url`,
COALESCE((SELECT own_follow.`status` FROM `sky_phone_picstagram_follows` own_follow
WHERE own_follow.`follower_id` = ? AND own_follow.`following_id` = candidate.`id` LIMIT 1), '') AS `follow_status`,
(SELECT COUNT(*) FROM `sky_phone_picstagram_follows` followers
WHERE followers.`following_id` = candidate.`id` AND followers.`status` = 'accepted') AS `followers`,
(SELECT COUNT(*) FROM `sky_phone_picstagram_follows` following
WHERE following.`follower_id` = candidate.`id` AND following.`status` = 'accepted') AS `following`,
(SELECT COUNT(*) FROM `sky_phone_picstagram_posts` post
WHERE post.`profile_id` = candidate.`id` AND post.`status` = 'published') AS `post_count`
FROM `sky_phone_picstagram_follows` follow
JOIN `sky_phone_picstagram_profiles` candidate ON candidate.`id` = %s
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = candidate.`avatar_media_id`
WHERE %s = ? AND follow.`status` = 'accepted' AND candidate.`status` = 'active'
AND NOT EXISTS(SELECT 1 FROM `sky_phone_picstagram_blocks` block
WHERE (block.`blocker_id` = ? AND block.`blocked_id` = candidate.`id`)
OR (block.`blocked_id` = ? AND block.`blocker_id` = candidate.`id`))
ORDER BY candidate.`display_name`, candidate.`handle` LIMIT 200
]]):format(join_column, filter_column), { viewer.id, target_id, viewer.id, viewer.id })
for index = 1, #rows do
hydrate_profile(rows[index], viewer.id)
end
return { success = true, data = rows }
end)
Bridge.Callbacks.Register("sky_phone:picstagram:update-profile", function(source, data)
local profile, error_response = profile_for_session(source)
if not profile then
@@ -642,7 +685,9 @@ Bridge.Callbacks.Register("sky_phone:picstagram:publish-post", function(source,
if not profile then
return error_response
end
if type(data) ~= "table" or type(data.mediaIds) ~= "table" or type(data.commentsEnabled) ~= "boolean" then
if type(data) ~= "table" or type(data.mediaIds) ~= "table" or type(data.commentsEnabled) ~= "boolean"
or (data.mediaType ~= "photo" and data.mediaType ~= "video")
then
return { success = false, error = "invalid_post" }
end
local caption = trim(data.caption) or ""
@@ -651,6 +696,7 @@ Bridge.Callbacks.Register("sky_phone:picstagram:publish-post", function(source,
or not valid_text(location, 0, Config.Picstagram.LocationMaxLength)
or #data.mediaIds < 1
or #data.mediaIds > Config.Picstagram.MaxPostMedia
or (data.mediaType == "video" and #data.mediaIds ~= 1)
then
return { success = false, error = "invalid_post" }
end
@@ -661,7 +707,7 @@ Bridge.Callbacks.Register("sky_phone:picstagram:publish-post", function(source,
if not media_id or media_id < 1 or media_id ~= math.floor(media_id) or seen[media_id] then
return { success = false, error = "invalid_media" }
end
if not SkyPhoneMedia.ResolveOwnedMedia(source, tostring(media_id), "photo") then
if not SkyPhoneMedia.ResolveOwnedMedia(source, tostring(media_id), data.mediaType) then
return { success = false, error = "invalid_media" }
end
seen[media_id] = true
@@ -864,25 +910,75 @@ Bridge.Callbacks.Register("sky_phone:picstagram:comments", function(source, data
SELECT comment.`id`, comment.`profile_id`, comment.`parent_id`, comment.`body`,
UNIX_TIMESTAMP(comment.`created_at`) AS `created_at_unix`, author.`handle`,
author.`display_name`, author.`verified`, avatar.`url` AS `avatar_url`,
(comment.`profile_id` = ?) AS `is_owner`
parent_author.`handle` AS `reply_to_handle`,
(comment.`profile_id` = ?) AS `is_owner`,
EXISTS(SELECT 1 FROM `sky_phone_picstagram_comment_reactions` reaction
WHERE reaction.`comment_id` = comment.`id` AND reaction.`profile_id` = ?) AS `is_liked`,
(SELECT COUNT(*) FROM `sky_phone_picstagram_comment_reactions` reaction
WHERE reaction.`comment_id` = comment.`id`) AS `like_count`
FROM `sky_phone_picstagram_comments` comment
JOIN `sky_phone_picstagram_profiles` author ON author.`id` = comment.`profile_id`
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = author.`avatar_media_id`
LEFT JOIN `sky_phone_picstagram_comments` parent ON parent.`id` = comment.`parent_id`
LEFT JOIN `sky_phone_picstagram_profiles` parent_author ON parent_author.`id` = parent.`profile_id`
WHERE comment.`post_id` = ? AND comment.`status` = 'visible' AND author.`status` = 'active'
AND NOT EXISTS(SELECT 1 FROM `sky_phone_picstagram_blocks` block
WHERE (block.`blocker_id` = ? AND block.`blocked_id` = comment.`profile_id`)
OR (block.`blocked_id` = ? AND block.`blocker_id` = comment.`profile_id`))
ORDER BY comment.`created_at`, comment.`id` LIMIT ?
]], { profile.id, data.id, profile.id, profile.id, Config.Picstagram.CommentPageSize })
ORDER BY COALESCE(parent.`created_at`, comment.`created_at`),
comment.`parent_id` IS NOT NULL, comment.`created_at`, comment.`id` LIMIT ?
]], { profile.id, profile.id, data.id, profile.id, profile.id, Config.Picstagram.CommentPageSize })
for index = 1, #rows do
rows[index].verified = tonumber(rows[index].verified) == 1
rows[index].is_owner = tonumber(rows[index].is_owner) == 1
rows[index].is_liked = tonumber(rows[index].is_liked) == 1
rows[index].like_count = tonumber(rows[index].like_count) or 0
rows[index].created_at = (tonumber(rows[index].created_at_unix) or 0) * 1000
rows[index].created_at_unix = nil
end
return { success = true, data = rows }
end)
Bridge.Callbacks.Register("sky_phone:picstagram:comment-react", function(source, data)
local profile, error_response = profile_for_session(source)
if not profile then
return error_response
end
if not SkyPhone.AllowOperation(source, "picstagram:comment-react", 60, 60) then
return { success = false, error = "rate_limited" }
end
local comment_id = type(data) == "table" and data.id or nil
if not valid_id(comment_id) or type(data.active) ~= "boolean" then
return { success = false, error = "invalid_request" }
end
local comment = Bridge.Database.Query([[
SELECT comment.`profile_id`, comment.`post_id`
FROM `sky_phone_picstagram_comments` comment
WHERE comment.`id` = ? AND comment.`status` = 'visible' LIMIT 1
]], { comment_id })[1]
if not comment or not find_accessible_post(comment.post_id, profile.id) then
return { success = false, error = "invalid_comment" }
end
if are_profiles_blocked(profile.id, comment.profile_id) then
return { success = false, error = "blocked" }
end
if data.active then
local result = Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_picstagram_comment_reactions` (`comment_id`, `profile_id`)
VALUES (?, ?)
]], { comment_id, profile.id })
if affected_rows(result) > 0 then
create_activity(comment.profile_id, profile.id, "comment_like", comment.post_id)
end
else
Bridge.Database.Query([[
DELETE FROM `sky_phone_picstagram_comment_reactions`
WHERE `comment_id` = ? AND `profile_id` = ?
]], { comment_id, profile.id })
end
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:picstagram:comment", function(source, data)
if not SkyPhone.AllowOperation(source, "picstagram:comment", Config.Picstagram.CommentsPerMinute, 60) then
return { success = false, error = "rate_limited" }
@@ -905,16 +1001,32 @@ Bridge.Callbacks.Register("sky_phone:picstagram:comment", function(source, data)
if tonumber(post.comments_enabled) ~= 1 then
return { success = false, error = "comments_disabled" }
end
local parent_id = valid_id(data.parentId) and data.parentId or nil
local parent
if parent_id then
parent = Bridge.Database.Query([[
SELECT `id`, `profile_id` FROM `sky_phone_picstagram_comments`
WHERE `id` = ? AND `post_id` = ? AND `parent_id` IS NULL AND `status` = 'visible' LIMIT 1
]], { parent_id, data.id })[1]
if not parent then
if data.replyToId ~= nil and not valid_id(data.replyToId) then
return { success = false, error = "invalid_comment" }
end
local parent_id
local reply_target
if data.replyToId then
reply_target = Bridge.Database.Query([[
SELECT `id`, `profile_id`, `parent_id` FROM `sky_phone_picstagram_comments`
WHERE `id` = ? AND `post_id` = ? AND `status` = 'visible' LIMIT 1
]], { data.replyToId, data.id })[1]
if not reply_target or are_profiles_blocked(profile.id, reply_target.profile_id) then
return { success = false, error = "invalid_comment" }
end
parent_id = reply_target.parent_id or reply_target.id
elseif data.parentId then
if not valid_id(data.parentId) then
return { success = false, error = "invalid_comment" }
end
reply_target = Bridge.Database.Query([[
SELECT `id`, `profile_id`, `parent_id` FROM `sky_phone_picstagram_comments`
WHERE `id` = ? AND `post_id` = ? AND `parent_id` IS NULL AND `status` = 'visible' LIMIT 1
]], { data.parentId, data.id })[1]
if not reply_target or are_profiles_blocked(profile.id, reply_target.profile_id) then
return { success = false, error = "invalid_comment" }
end
parent_id = reply_target.id
end
local id = new_id()
if parent_id then
@@ -928,9 +1040,11 @@ Bridge.Callbacks.Register("sky_phone:picstagram:comment", function(source, data)
VALUES (?, ?, ?, ?)
]], { id, data.id, profile.id, body })
end
create_activity(post.profile_id, profile.id, "comment", data.id)
if parent and parent.profile_id ~= post.profile_id then
create_activity(parent.profile_id, profile.id, "comment", data.id)
if reply_target then
create_activity(reply_target.profile_id, profile.id, "reply", data.id)
end
if not reply_target or reply_target.profile_id ~= post.profile_id then
create_activity(post.profile_id, profile.id, "comment", data.id)
end
return { success = true, data = { id = id } }
end)
@@ -968,14 +1082,14 @@ Bridge.Callbacks.Register("sky_phone:picstagram:publish-story", function(source,
if not profile then
return error_response
end
if type(data) ~= "table" then
if type(data) ~= "table" or (data.mediaType ~= "photo" and data.mediaType ~= "video") then
return { success = false, error = "invalid_story" }
end
local media_id = tonumber(data.mediaId)
local body = trim(data.body) or ""
if not media_id or media_id < 1 or media_id ~= math.floor(media_id)
or not valid_text(body, 0, Config.Picstagram.StoryTextMaxLength)
or not SkyPhoneMedia.ResolveOwnedMedia(source, tostring(media_id), "photo")
or not SkyPhoneMedia.ResolveOwnedMedia(source, tostring(media_id), data.mediaType)
then
return { success = false, error = "invalid_story" }
end
@@ -994,7 +1108,7 @@ Bridge.Callbacks.Register("sky_phone:picstagram:stories", function(source)
return error_response
end
local rows = Bridge.Database.Query([[
SELECT story.`id`, story.`profile_id`, story.`body`, media.`url`, author.`handle`,
SELECT story.`id`, story.`profile_id`, story.`body`, media.`url`, media.`media_type`, author.`handle`,
author.`display_name`, author.`verified`, avatar.`url` AS `avatar_url`,
UNIX_TIMESTAMP(story.`created_at`) AS `created_at_unix`,
UNIX_TIMESTAMP(story.`expires_at`) AS `expires_at_unix`,
+12 -1
View File
@@ -725,6 +725,17 @@ CREATE TABLE IF NOT EXISTS `sky_phone_picstagram_comments` (
FOREIGN KEY (`parent_id`) REFERENCES `sky_phone_picstagram_comments` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_picstagram_comment_reactions` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`comment_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sky_phone_picstagram_comment_reaction` (`comment_id`,`profile_id`),
FOREIGN KEY (`comment_id`) REFERENCES `sky_phone_picstagram_comments` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_picstagram_stories` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
@@ -756,7 +767,7 @@ CREATE TABLE IF NOT EXISTS `sky_phone_picstagram_activities` (
`recipient_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`actor_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`post_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
`kind` ENUM('follow_request','follow','request_accepted','like','comment','verified') NOT NULL,
`kind` ENUM('follow_request','follow','request_accepted','like','comment','comment_like','reply','verified') NOT NULL,
`read_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),