diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a21e397..ec2dd24 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -33,6 +33,7 @@ import { useMessagesStore } from '@/stores/messages' import { useDarkChatStore } from '@/stores/darkchat' import { useFlareStore } from '@/stores/flare' import { useFlipTokStore } from '@/stores/fliptok' +import { usePicstagramStore } from '@/stores/picstagram' import { useMediaStore } from '@/stores/media' import { useMarketplaceStore } from '@/stores/marketplace' import { useAppStoreStore } from '@/stores/app-store' @@ -65,6 +66,8 @@ type AppMessage = { | FlareEventData | FlipTokVerificationData | FlipTokNotificationData + | PicstagramVerificationData + | PicstagramNotificationData | PhoneCall | PhoneNotificationInput | PhoneOpenPayload @@ -143,6 +146,20 @@ type FlipTokNotificationData = { title?: string videoId?: string } + +type PicstagramVerificationData = { + profileId: string + verified: boolean +} + +type PicstagramNotificationData = { + actor?: string + device?: PhoneNotificationDevicePayload + kind?: 'like' | 'comment' | 'follow' | 'follow_request' | 'verified' + postId?: string + text?: string + title?: string +} const REFERENCE_VIEWPORT_WIDTH = 1920 const REFERENCE_VIEWPORT_HEIGHT = 1080 const PHONE_BASE_SCALE = 0.69 @@ -160,6 +177,7 @@ const messages = useMessagesStore() const darkchat = useDarkChatStore() const flare = useFlareStore() const fliptok = useFlipTokStore() +const picstagram = usePicstagramStore() const media = useMediaStore() const marketplace = useMarketplaceStore() const appStore = useAppStoreStore() @@ -333,6 +351,32 @@ function onMessage(event: MessageEvent): void { } notifications.show(notification) if (phone.isOpen) void fliptok.loadActivities() + } else if ( + event.data?.type === 'picstagram:verification-changed' && + event.data.data + ) { + const data = event.data.data as PicstagramVerificationData + picstagram.applyVerification(String(data.profileId), data.verified === true) + } else if (event.data?.type === 'picstagram:new' && event.data.data) { + const data = event.data.data as PicstagramNotificationData + const notification: PhoneNotificationInput = { + appId: 'picstagram', + subtitle: data.actor, + text: data.text ?? phone.t('Apps.picstagram.notifications.default'), + title: data.title ?? phone.t('Apps.picstagram.name'), + } + if ( + data.device && + (!phone.isOpen || data.device.imei !== phone.device?.imei) + ) { + notification.device = { + imei: data.device.imei, + name: data.device.name, + preferences: parsePhonePreferences(data.device.settings ?? null), + } + } + notifications.show(notification) + if (phone.isOpen) void picstagram.loadActivities() } else if ( event.data?.type === 'marketplace:new-message' && event.data.data diff --git a/frontend/src/assets/img/app-icons/picstagram.webp b/frontend/src/assets/img/app-icons/picstagram.webp new file mode 100644 index 0000000..6512bb2 Binary files /dev/null and b/frontend/src/assets/img/app-icons/picstagram.webp differ diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index 6cde0b3..4bac4c3 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -124,11 +124,12 @@ describe('app registry', () => { PHONE_APPS.filter((app) => app.category === 'social').map( (app) => app.id, ), - ).toEqual([ - 'fliptok', - 'flare', - 'radio', - 'local-pages', + ).toEqual([ + 'picstagram', + 'fliptok', + 'flare', + 'radio', + 'local-pages', 'phone', 'darkchat', 'banking', diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index a029391..ecba1bf 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -57,6 +57,7 @@ import citymarktIcon from '@/assets/img/app-icons/citymarkt.webp' import localPagesIcon from '@/assets/img/app-icons/local-pages.webp' import flareIcon from '@/assets/img/app-icons/flare.svg' import flipTokIcon from '@/assets/img/app-icons/fliptok.webp' +import picstagramIcon from '@/assets/img/app-icons/picstagram.webp' import type { LaunchablePhoneAppDefinition, LaunchablePhoneAppId, @@ -64,6 +65,20 @@ import type { } from '@/types/apps' export const PHONE_APPS: PhoneAppDefinition[] = [ + { + category: 'social', + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/PicstagramApp.vue')), + ), + dockOrder: null, + gridOrder: 23, + icon: markRaw(Camera), + iconClass: 'app-icon--picstagram', + iconImage: picstagramIcon, + id: 'picstagram', + labelKey: 'Apps.picstagram.name', + route: '/apps/picstagram', + }, { category: 'social', component: markRaw( diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index e14bbc1..db125cc 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -191,6 +191,204 @@ const defaultLocales: LocaleTree = { default: 'Flare could not complete the request.', }, }, + picstagram: { + name: 'Picstagram', + loading: 'Loading Picstagram', + home: 'Home', + explore: 'Explore', + create: 'Create', + activity: 'Activity', + profile: 'Profile', + following: 'Following', + city: 'City', + posts: 'Posts', + followers: 'Followers', + verified: 'Verified profile', + editProfile: 'Edit profile', + saved: 'Saved', + photos: 'Photos', + emptyBio: 'No bio yet.', + authTitle: 'Your Picstagram profile', + login: 'Sign In', + register: 'Register', + createAccount: 'Create Profile', + logout: 'Sign Out', + signingOut: 'Signing Out...', + loginBody: + 'Sign in to continue with your photos, stories, follows, and saved posts.', + registerBody: 'Create a private Picstagram login for your photo profile.', + registrationHint: + 'An iFruit account is required once to create and own a Picstagram profile.', + displayName: 'Name', + username: 'Username', + password: 'Password', + confirmPassword: 'Confirm password', + passwordsMismatch: 'The passwords do not match.', + displayNamePlaceholder: 'Your name', + usernamePlaceholder: 'username', + passwordPlaceholder: 'At least 8 characters', + confirmPasswordPlaceholder: 'Enter password again', + signOutTitle: 'Sign out of Picstagram?', + signOutBody: + 'Your profile and posts stay online. This phone returns to the sign-in screen.', + stories: 'Stories', + yourStory: 'Your story', + noStories: 'No active stories', + storyViews: '{count} views', + storyViewers: 'Story viewers', + seenBy: 'Seen by {count}', + nextStory: 'Next story', + closeStory: 'Close story', + removeStory: 'Remove story', + storyRemoved: 'Story removed.', + emptyFeed: 'Your feed is quiet', + emptyFeedBody: 'Discover profiles or share the first photo.', + 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 Gallery', + chooseStoryHint: 'Select one photo from Gallery', + 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', + 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', + 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', + avatar: 'Profile photo', + chooseAvatar: 'Choose profile photo', + removeAvatar: 'Remove profile photo', + saveProfile: 'Save profile', + profileSaved: 'Profile updated.', + cancel: 'Cancel', + done: 'Done', + report: 'Report', + reportTarget: 'Report {target}', + reportReason: 'Reason', + reportDetails: 'Additional details (optional)', + submitReport: 'Submit report', + reported: 'Report submitted.', + block: 'Block profile', + unblock: 'Unblock profile', + blocked: 'Profile blocked.', + blockTitle: 'Block @{handle}?', + blockBody: + "You will no longer see or interact with each other's Picstagram content.", + moderation: 'Moderation', + reports: 'Open reports', + noReports: 'No open reports', + 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.', + }, + 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.', + comments_disabled: 'Comments are disabled.', + invalid_profile: 'Check your profile details.', + invalid_handle: 'Use 3–24 letters, numbers, dots, or underscores.', + invalid_display_name: 'Enter a display name.', + invalid_password: 'Password must be 8–72 characters.', + invalid_credentials: 'Username or password is incorrect.', + already_registered: + 'This iFruit account already owns a Picstagram profile.', + handle_taken: 'This username is already taken.', + post_not_found: 'This post is unavailable.', + story_not_found: 'This story is unavailable.', + profile_not_found: 'This profile is unavailable.', + request_not_found: 'This follow request is no longer open.', + profile_unavailable: 'This profile is unavailable.', + blocked: 'This profile is blocked.', + already_reported: 'You already reported this content.', + invalid_report: 'Choose a valid report reason.', + invalid_search: 'Enter a valid search.', + invalid_cursor: 'The feed changed. Refresh and try again.', + invalid_status: 'This action is not available.', + not_authorized: 'You are not allowed to do that.', + report_not_found: 'This report is no longer open.', + rate_limited: 'Too many actions. Try again shortly.', + not_authenticated: 'Sign in to iFruit first.', + picstagram_not_authenticated: 'Sign in to Picstagram first.', + request_failed: 'The request failed.', + default: 'Picstagram could not complete the request.', + }, + }, fliptok: { name: 'FlipTok', loading: 'Loading FlipTok', diff --git a/frontend/src/stores/picstagram.test.ts b/frontend/src/stores/picstagram.test.ts new file mode 100644 index 0000000..d23e934 --- /dev/null +++ b/frontend/src/stores/picstagram.test.ts @@ -0,0 +1,216 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { usePicstagramStore } from '@/stores/picstagram' +import type { + PicstagramActivity, + PicstagramComment, + PicstagramPost, + PicstagramProfile, + PicstagramStory, +} from '@/types/picstagram' +import { nuiCall } from '@/utils/nui' + +vi.mock('@/utils/nui', () => ({ + nuiCall: vi.fn(), +})) + +const profile: PicstagramProfile = { + avatar_media_id: null, + avatar_url: null, + bio: 'City light collector.', + display_name: 'Nova', + follow_status: null, + followers: 12, + following: 4, + handle: 'nova', + id: 'profile-1', + is_following: false, + is_owner: true, + is_requested: false, + locked: false, + post_count: 1, + private: false, + status: 'active', + verified: false, +} + +const post: PicstagramPost = { + avatar_url: null, + caption: 'Los Santos after rain.', + comment_count: 1, + comments_enabled: true, + created_at: 1, + display_name: 'Nova', + handle: 'nova', + id: 'post-1', + is_liked: false, + is_owner: true, + is_saved: false, + like_count: 2, + location: 'Downtown', + media: [{ id: 1, position: 0, url: 'https://example.com/photo.webp' }], + private: false, + profile_id: profile.id, + verified: false, +} + +const comment: PicstagramComment = { + avatar_url: null, + body: 'Beautiful.', + created_at: 1, + display_name: 'Nova', + handle: 'nova', + id: 'comment-1', + is_owner: true, + parent_id: null, + profile_id: profile.id, + verified: false, +} + +const activity: PicstagramActivity = { + avatar_url: null, + created_at: 1, + display_name: 'Nova', + handle: 'nova', + id: 'activity-1', + kind: 'follow', + post_id: null, + post_url: null, + profile_id: profile.id, + read_at: null, + verified: false, +} + +const story: PicstagramStory = { + avatar_url: null, + body: 'Tonight.', + created_at: 1, + display_name: 'Nova', + expires_at: 2, + handle: 'nova', + id: 'story-1', + is_owner: true, + profile_id: profile.id, + seen: true, + url: 'https://example.com/story.webp', + verified: false, + view_count: 3, +} + +describe('Picstagram store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.mocked(nuiCall).mockReset() + }) + + it('updates verification on every visible surface', () => { + const store = usePicstagramStore() + store.profile = { ...profile } + store.feed = [{ ...post }] + store.explore = [{ ...post }] + store.profilePosts = [{ ...post }] + store.saved = [{ ...post }] + store.searchPosts = [{ ...post }] + store.comments = [{ ...comment }] + store.activities = [{ ...activity }] + + store.applyVerification(profile.id, true) + + expect(store.profile.verified).toBe(true) + expect( + store + .allPostCollections() + .flat() + .every((item) => item.verified), + ).toBe(true) + expect(store.comments[0].verified).toBe(true) + expect(store.activities[0].verified).toBe(true) + }) + + it('rolls back an optimistic like when the server rejects it', async () => { + vi.mocked(nuiCall).mockResolvedValue({ success: false }) + const store = usePicstagramStore() + store.feed = [{ ...post }] + store.explore = [{ ...post }] + + expect(await store.react(store.feed[0], 'like')).toBe(false) + expect(store.feed[0].is_liked).toBe(false) + expect(store.feed[0].like_count).toBe(2) + expect(store.explore[0].is_liked).toBe(false) + expect(store.explore[0].like_count).toBe(2) + }) + + it('removes a blocked profile from all local surfaces', async () => { + vi.mocked(nuiCall).mockResolvedValue({ success: true }) + const store = usePicstagramStore() + store.feed = [{ ...post }] + store.explore = [{ ...post }] + store.profilePosts = [{ ...post }] + store.saved = [{ ...post }] + store.searchPosts = [{ ...post }] + store.comments = [{ ...comment }] + store.activities = [{ ...activity }] + store.stories = [{ ...story }] + store.viewedProfile = { ...profile, is_owner: false } + + expect(await store.blockProfile(profile.id)).toBe(true) + expect(store.allPostCollections().flat()).toEqual([]) + expect(store.comments).toEqual([]) + expect(store.activities).toEqual([]) + expect(store.stories).toEqual([]) + expect(store.viewedProfile).toBeNull() + }) + + it('keeps the app signed out when no Picstagram session exists', async () => { + vi.mocked(nuiCall).mockResolvedValue({ + success: true, + data: { authenticated: false, isAdmin: false }, + }) + const store = usePicstagramStore() + + expect(await store.bootstrap()).toBe(true) + expect(store.authenticated).toBe(false) + expect(store.profile).toBeNull() + expect(store.feed).toEqual([]) + }) + + it('hydrates the profile and stories after login', async () => { + vi.mocked(nuiCall) + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ + success: true, + data: { + authenticated: true, + feed: { hasMore: false, items: [{ ...post }], nextCursor: null }, + isAdmin: false, + profile: { ...profile }, + }, + }) + .mockResolvedValueOnce({ success: true, data: [{ ...story }] }) + const store = usePicstagramStore() + + expect((await store.login('nova', 'password123')).success).toBe(true) + expect(store.profile?.handle).toBe('nova') + expect(store.stories).toHaveLength(1) + expect(nuiCall).toHaveBeenNthCalledWith(1, 'picstagram:login', { + handle: 'nova', + password: 'password123', + }) + }) + + it('clears all local state after logout', async () => { + vi.mocked(nuiCall).mockResolvedValue({ success: true }) + const store = usePicstagramStore() + store.authenticated = true + store.profile = { ...profile } + store.feed = [{ ...post }] + store.stories = [{ ...story }] + + expect((await store.logout()).success).toBe(true) + expect(store.authenticated).toBe(false) + expect(store.profile).toBeNull() + expect(store.feed).toEqual([]) + expect(store.stories).toEqual([]) + }) +}) diff --git a/frontend/src/stores/picstagram.ts b/frontend/src/stores/picstagram.ts new file mode 100644 index 0000000..eee6c31 --- /dev/null +++ b/frontend/src/stores/picstagram.ts @@ -0,0 +1,399 @@ +import { defineStore } from 'pinia' + +import type { + PicstagramActivity, + PicstagramComment, + PicstagramPage, + PicstagramPost, + PicstagramProfile, + PicstagramProfilePage, + PicstagramReport, + PicstagramReportReason, + PicstagramReportTarget, + PicstagramSearchResult, + PicstagramStory, + PicstagramStoryViewer, +} from '@/types/picstagram' +import { nuiCall, type NuiResponse } from '@/utils/nui' + +function replaceProfileState( + posts: PicstagramPost[], + profileId: string, + update: (post: PicstagramPost) => void, +): void { + posts.filter((post) => post.profile_id === profileId).forEach(update) +} + +export const usePicstagramStore = defineStore('picstagram', { + state: () => ({ + activities: [] as PicstagramActivity[], + authenticated: false, + comments: [] as PicstagramComment[], + explore: [] as PicstagramPost[], + exploreCursor: null as string | null, + feed: [] as PicstagramPost[], + feedCursor: null as string | null, + isAdmin: false, + loading: false, + profile: null as PicstagramProfile | null, + profilePosts: [] as PicstagramPost[], + reports: [] as PicstagramReport[], + saved: [] as PicstagramPost[], + searchPosts: [] as PicstagramPost[], + searchProfiles: [] as PicstagramProfile[], + stories: [] as PicstagramStory[], + storyViewers: [] as PicstagramStoryViewer[], + viewedProfile: null as PicstagramProfile | null, + }), + actions: { + allPostCollections(): PicstagramPost[][] { + return [ + this.feed, + this.explore, + this.profilePosts, + this.saved, + this.searchPosts, + ] + }, + applyVerification(profileId: string, verified: boolean): void { + if (this.profile?.id === profileId) this.profile.verified = verified + if (this.viewedProfile?.id === profileId) + this.viewedProfile.verified = verified + this.allPostCollections().forEach((posts) => + replaceProfileState(posts, profileId, (post) => { + post.verified = verified + }), + ) + this.comments + .filter((comment) => comment.profile_id === profileId) + .forEach((comment) => { + comment.verified = verified + }) + this.activities + .filter((activity) => activity.profile_id === profileId) + .forEach((activity) => { + activity.verified = verified + }) + }, + async bootstrap(): Promise { + this.loading = true + const response = await nuiCall<{ + authenticated: boolean + feed?: PicstagramPage + isAdmin: boolean + profile?: PicstagramProfile + }>('picstagram:bootstrap') + this.loading = false + if (!response.success || !response.data) return false + this.authenticated = response.data.authenticated + this.isAdmin = response.data.isAdmin + this.profile = response.data.profile ?? null + this.feed = response.data.feed?.items ?? [] + this.feedCursor = response.data.feed?.nextCursor ?? null + if (this.authenticated) await this.loadStories() + return true + }, + async login(handle: string, password: string): Promise { + const response = await nuiCall('picstagram:login', { handle, password }) + if (response.success) await this.bootstrap() + return response + }, + async register( + displayName: string, + handle: string, + password: string, + ): Promise { + const response = await nuiCall('picstagram:register', { + displayName, + handle, + password, + }) + if (response.success) await this.bootstrap() + return response + }, + async logout(): Promise { + const response = await nuiCall('picstagram:logout') + if (response.success) this.$reset() + return response + }, + async loadFeed(append = false): Promise { + const response = await nuiCall('picstagram:feed', { + cursor: append ? this.feedCursor : undefined, + }) + if (!response.success || !response.data) return false + this.feed = append + ? [...this.feed, ...response.data.items] + : response.data.items + this.feedCursor = response.data.nextCursor + return true + }, + async loadExplore(append = false): Promise { + const response = await nuiCall('picstagram:explore', { + cursor: append ? this.exploreCursor : undefined, + }) + if (!response.success || !response.data) return false + this.explore = append + ? [...this.explore, ...response.data.items] + : response.data.items + this.exploreCursor = response.data.nextCursor + return true + }, + async search(search: string): Promise { + if (!search.trim()) { + this.searchPosts = [] + this.searchProfiles = [] + return true + } + const response = await nuiCall( + 'picstagram:search', + { search }, + ) + if (!response.success || !response.data) return false + this.searchPosts = response.data.posts + this.searchProfiles = response.data.profiles + return true + }, + async loadSaved(): Promise { + const response = await nuiCall('picstagram:saved') + if (!response.success || !response.data) return false + this.saved = response.data.items + return true + }, + async loadProfile(query: { + handle?: string + profileId?: string + }): Promise { + const response = await nuiCall( + 'picstagram:profile', + query, + ) + if (!response.success || !response.data) return false + this.viewedProfile = response.data.profile + this.profilePosts = response.data.posts.items + return true + }, + showOwnProfile(): void { + this.viewedProfile = null + this.profilePosts = this.feed.filter((post) => post.is_owner) + }, + async updateProfile(payload: { + avatarMediaId: number + bio: string + displayName: string + handle: string + private: boolean + }): Promise> { + const response = await nuiCall( + 'picstagram:update-profile', + payload, + ) + if (response.success && response.data) this.profile = response.data + return response + }, + async publishPost(payload: { + caption: string + commentsEnabled: boolean + location: string + mediaIds: number[] + }): Promise> { + const response = await nuiCall<{ id: string }>( + 'picstagram:publish-post', + payload, + ) + if (response.success) await this.loadFeed() + return response + }, + async setPostStatus( + post: PicstagramPost, + status: 'archived' | 'published' | 'removed', + ): Promise { + const response = await nuiCall('picstagram:set-post-status', { + id: post.id, + status, + }) + if (!response.success) return false + this.allPostCollections().forEach((posts) => { + const index = posts.findIndex((item) => item.id === post.id) + if (index >= 0) posts.splice(index, 1) + }) + return true + }, + async react(post: PicstagramPost, kind: 'like' | 'save'): Promise { + const key = kind === 'like' ? 'is_liked' : 'is_saved' + const next = !post[key] + const matching = this.allPostCollections() + .flat() + .filter((item) => item.id === post.id) + matching.forEach((item) => { + item[key] = next + if (kind === 'like') item.like_count += next ? 1 : -1 + }) + const response = await nuiCall('picstagram:react', { + active: next, + id: post.id, + kind, + }) + if (response.success) return true + matching.forEach((item) => { + item[key] = !next + if (kind === 'like') item.like_count += next ? -1 : 1 + }) + return false + }, + async followProfile(profile: PicstagramProfile): Promise { + const active = !profile.is_following && !profile.is_requested + const response = await nuiCall<{ + status: 'accepted' | 'pending' | false + }>('picstagram:follow', { active, profileId: profile.id }) + if (!response.success || !response.data) return false + const previousFollowing = profile.is_following + profile.follow_status = response.data.status || null + profile.is_following = response.data.status === 'accepted' + profile.is_requested = response.data.status === 'pending' + if (previousFollowing !== profile.is_following) + profile.followers += profile.is_following ? 1 : -1 + return true + }, + async respondFollow(profileId: string, accept: boolean): Promise { + const response = await nuiCall('picstagram:respond-follow', { + accept, + profileId, + }) + if (response.success) + this.activities = this.activities.filter( + (activity) => + activity.kind !== 'follow_request' || + activity.profile_id !== profileId, + ) + return response.success + }, + async loadComments(id: string): Promise { + const response = await nuiCall( + 'picstagram:comments', + { id }, + ) + this.comments = response.success && response.data ? response.data : [] + return response.success + }, + async comment( + id: string, + body: string, + parentId?: string, + ): Promise { + return nuiCall('picstagram:comment', { body, id, parentId }) + }, + async removeComment(id: string): Promise { + const response = await nuiCall('picstagram:remove-comment', { id }) + if (response.success) + this.comments = this.comments.filter((comment) => comment.id !== id) + return response.success + }, + async loadStories(): Promise { + const response = await nuiCall('picstagram:stories') + this.stories = response.success && response.data ? response.data : [] + return response.success + }, + async publishStory( + mediaId: number, + body: string, + ): Promise> { + const response = await nuiCall<{ id: string }>( + 'picstagram:publish-story', + { body, mediaId }, + ) + if (response.success) await this.loadStories() + return response + }, + async viewStory(story: PicstagramStory): Promise { + if (!story.seen && !story.is_owner) { + const response = await nuiCall('picstagram:view-story', { + id: story.id, + }) + if (response.success) story.seen = true + } + }, + async loadStoryViewers(id: string): Promise { + const response = await nuiCall( + 'picstagram:story-viewers', + { id }, + ) + this.storyViewers = response.success && response.data ? response.data : [] + return response.success + }, + async removeStory(id: string): Promise { + const response = await nuiCall('picstagram:remove-story', { id }) + if (response.success) + this.stories = this.stories.filter((story) => story.id !== id) + return response.success + }, + async loadActivities(): Promise { + const response = await nuiCall( + 'picstagram:activities', + ) + this.activities = response.success && response.data ? response.data : [] + return response.success + }, + async markActivities(): Promise { + const response = await nuiCall('picstagram:mark-activities') + if (response.success) + this.activities.forEach((activity) => { + activity.read_at ??= new Date().toISOString() + }) + return response.success + }, + async blockProfile(profileId: string): Promise { + const response = await nuiCall('picstagram:block', { + active: true, + profileId, + }) + if (!response.success) return false + this.allPostCollections().forEach((posts) => { + for (let index = posts.length - 1; index >= 0; index -= 1) + if (posts[index].profile_id === profileId) posts.splice(index, 1) + }) + this.comments = this.comments.filter( + (comment) => comment.profile_id !== profileId, + ) + this.activities = this.activities.filter( + (activity) => activity.profile_id !== profileId, + ) + this.stories = this.stories.filter( + (story) => story.profile_id !== profileId, + ) + if (this.viewedProfile?.id === profileId) this.viewedProfile = null + return true + }, + async report( + targetType: PicstagramReportTarget, + targetId: string, + reason: PicstagramReportReason, + details: string, + ): Promise { + return nuiCall('picstagram:report', { + details, + reason, + targetId, + targetType, + }) + }, + async loadReports(): Promise { + const response = await nuiCall( + 'picstagram:admin-reports', + ) + this.reports = response.success && response.data ? response.data : [] + return response.success + }, + async resolveReport( + id: string, + action: 'dismiss' | 'hide' | 'remove' | 'restore', + ): Promise { + const response = await nuiCall('picstagram:admin-resolve-report', { + action, + id, + }) + if (response.success) + this.reports = this.reports.filter((report) => report.id !== id) + return response.success + }, + }, +}) diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index 0a0c31d..064c4c4 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -29,6 +29,7 @@ export type PhoneAppId = | 'local-pages' | 'flare' | 'fliptok' + | 'picstagram' export type LaunchablePhoneAppId = PhoneAppId diff --git a/frontend/src/types/picstagram.ts b/frontend/src/types/picstagram.ts new file mode 100644 index 0000000..07fc5ea --- /dev/null +++ b/frontend/src/types/picstagram.ts @@ -0,0 +1,141 @@ +export type PicstagramProfile = { + avatar_media_id: number | null + avatar_url: string | null + bio: string + display_name: string + follow_status: 'accepted' | 'pending' | null + followers: number + following: number + handle: string + id: string + is_following: boolean + is_owner: boolean + is_requested: boolean + locked: boolean + post_count: number + private: boolean + status: 'active' + verified: boolean +} + +export type PicstagramPostMedia = { + id: number + position: number + url: string +} + +export type PicstagramPost = { + avatar_url: string | null + caption: string + comment_count: number + comments_enabled: boolean + created_at: number + display_name: string + handle: string + id: string + is_liked: boolean + is_owner: boolean + is_saved: boolean + like_count: number + location: string + media: PicstagramPostMedia[] + private: boolean + profile_id: string + verified: boolean +} + +export type PicstagramPage = { + hasMore: boolean + items: PicstagramPost[] + nextCursor: string | null +} + +export type PicstagramProfilePage = { + posts: PicstagramPage + profile: PicstagramProfile +} + +export type PicstagramSearchResult = { + posts: PicstagramPost[] + profiles: PicstagramProfile[] +} + +export type PicstagramComment = { + avatar_url: string | null + body: string + created_at: number + display_name: string + handle: string + id: string + is_owner: boolean + parent_id: string | null + profile_id: string + verified: boolean +} + +export type PicstagramStory = { + avatar_url: string | null + body: string + created_at: number + display_name: string + expires_at: number + handle: string + id: string + is_owner: boolean + profile_id: string + seen: boolean + url: string + verified: boolean + view_count: number +} + +export type PicstagramStoryViewer = { + avatar_url: string | null + created_at: number + display_name: string + handle: string + id: string + verified: boolean +} + +export type PicstagramActivityKind = + | 'follow_request' + | 'follow' + | 'request_accepted' + | 'like' + | 'comment' + | 'verified' + +export type PicstagramActivity = { + avatar_url: string | null + created_at: number + display_name: string + handle: string + id: string + kind: PicstagramActivityKind + post_id: string | null + post_url: string | null + profile_id: string + read_at: string | null + verified: boolean +} + +export type PicstagramReportTarget = 'profile' | 'post' | 'story' | 'comment' + +export type PicstagramReportReason = + | 'spam' + | 'harassment' + | 'dangerous' + | 'illegal' + | 'other' + +export type PicstagramReport = { + created_at: number + details: string + id: string + reason: PicstagramReportReason + reporter_display_name: string + reporter_handle: string + target_id: string + target_type: PicstagramReportTarget +} diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 39ab24f..baf72b2 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -82,6 +82,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record< 'neon-drop': { enabled: true, sounds: true }, citymarkt: { enabled: true, sounds: true }, 'local-pages': { enabled: true, sounds: true }, + picstagram: { enabled: true, sounds: true }, fliptok: { enabled: true, sounds: true }, camera: { enabled: true, sounds: true }, clock: { enabled: true, sounds: true }, diff --git a/frontend/src/views/apps/PicstagramApp.vue b/frontend/src/views/apps/PicstagramApp.vue new file mode 100644 index 0000000..d0a2e3c --- /dev/null +++ b/frontend/src/views/apps/PicstagramApp.vue @@ -0,0 +1,2805 @@ + + + + + diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index cad4cc7..5ffcff7 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -176,6 +176,183 @@ let flipTokReports = [ creator_display_name: 'Nova', }, ] +let picstagramAuthenticated = true +const picstagramProfiles = [ + { + avatar_media_id: null, + avatar_url: + 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=240&q=80', + bio: 'Street light, rain, and the quiet side of Los Santos.', + display_name: 'Skyline', + follow_status: null, + followers: 1842, + following: 128, + handle: 'skyline', + id: 'pic-profile-1', + is_following: false, + is_owner: true, + is_requested: false, + locked: false, + post_count: 1, + private: false, + status: 'active', + verified: true, + }, + { + avatar_media_id: null, + avatar_url: + 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=240&q=80', + bio: 'Golden-hour wanderer.', + display_name: 'Nova', + follow_status: 'accepted', + followers: 9214, + following: 91, + handle: 'nova.ls', + id: 'pic-profile-2', + is_following: true, + is_owner: false, + is_requested: false, + locked: false, + post_count: 2, + private: false, + status: 'active', + verified: true, + }, + { + avatar_media_id: null, + avatar_url: null, + bio: 'Private film diary.', + display_name: 'Milo Reed', + follow_status: null, + followers: 318, + following: 44, + handle: 'milo.reed', + id: 'pic-profile-3', + is_following: false, + is_owner: false, + is_requested: false, + locked: true, + post_count: 0, + private: true, + status: 'active', + verified: false, + }, +] +let picstagramPosts = [ + { + avatar_url: picstagramProfiles[1].avatar_url, + caption: 'The city holds its breath right before sunrise. #LosSantos', + comment_count: 2, + comments_enabled: true, + created_at: Date.now() - 32 * 60 * 1000, + display_name: picstagramProfiles[1].display_name, + handle: picstagramProfiles[1].handle, + id: 'pic-post-1', + is_liked: false, + is_owner: false, + is_saved: true, + like_count: 842, + location: 'Vinewood Hills', + media: [ + { + id: 8101, + position: 0, + url: 'https://images.unsplash.com/photo-1518005020951-eccb494ad742?auto=format&fit=crop&w=900&q=85', + }, + { + id: 8102, + position: 1, + url: 'https://images.unsplash.com/photo-1444723121867-7a241cacace9?auto=format&fit=crop&w=900&q=85', + }, + ], + private: false, + profile_id: picstagramProfiles[1].id, + verified: true, + }, + { + avatar_url: picstagramProfiles[0].avatar_url, + caption: 'Neon after rain.', + comment_count: 1, + comments_enabled: true, + created_at: Date.now() - 3 * 60 * 60 * 1000, + display_name: picstagramProfiles[0].display_name, + handle: picstagramProfiles[0].handle, + id: 'pic-post-2', + is_liked: true, + is_owner: true, + is_saved: false, + like_count: 216, + location: 'Downtown Los Santos', + media: [ + { + id: 8103, + position: 0, + url: 'https://images.unsplash.com/photo-1519608487953-e999c86e7455?auto=format&fit=crop&w=900&q=85', + }, + ], + private: false, + profile_id: picstagramProfiles[0].id, + verified: true, + }, +] +let picstagramComments = [ + { + avatar_url: picstagramProfiles[1].avatar_url, + body: 'That reflection is unreal.', + created_at: Date.now() - 18 * 60 * 1000, + display_name: picstagramProfiles[1].display_name, + handle: picstagramProfiles[1].handle, + id: 'pic-comment-1', + is_owner: false, + parent_id: null, + profile_id: picstagramProfiles[1].id, + verified: true, + }, +] +let picstagramStories = [ + { + avatar_url: picstagramProfiles[1].avatar_url, + body: 'First light over Vinewood.', + created_at: Date.now() - 12 * 60 * 1000, + display_name: picstagramProfiles[1].display_name, + expires_at: Date.now() + 22 * 60 * 60 * 1000, + handle: picstagramProfiles[1].handle, + id: 'pic-story-1', + is_owner: false, + profile_id: picstagramProfiles[1].id, + seen: false, + url: 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=900&q=85', + verified: true, + view_count: 42, + }, +] +let picstagramActivities = [ + { + avatar_url: picstagramProfiles[1].avatar_url, + created_at: Date.now() - 9 * 60 * 1000, + display_name: picstagramProfiles[1].display_name, + handle: picstagramProfiles[1].handle, + id: 'pic-activity-1', + kind: 'like', + post_id: 'pic-post-2', + post_url: picstagramPosts[1].media[0].url, + profile_id: picstagramProfiles[1].id, + read_at: null, + verified: true, + }, +] +let picstagramReports = [ + { + created_at: Date.now() - 45 * 60 * 1000, + details: 'Please review the caption and location.', + id: 'pic-report-1', + reason: 'spam', + reporter_display_name: 'Skyline', + reporter_handle: 'skyline', + target_id: 'pic-post-1', + target_type: 'post', + }, +] const mockBankTransactions = [ { id: 1, @@ -1548,6 +1725,360 @@ app.post('/api/:endpoint', (request, response) => { response.json({ success: true, data: message }) return } + if (endpoint === 'picstagram:bootstrap') { + response.json({ + success: true, + data: picstagramAuthenticated + ? { + authenticated: true, + feed: { + hasMore: false, + items: picstagramPosts, + nextCursor: null, + }, + isAdmin: true, + profile: picstagramProfiles[0], + } + : { authenticated: false, isAdmin: true }, + }) + return + } + if (endpoint === 'picstagram:login' || endpoint === 'picstagram:register') { + picstagramAuthenticated = true + response.json({ success: true }) + return + } + if (endpoint === 'picstagram:logout') { + picstagramAuthenticated = false + response.json({ success: true }) + return + } + if (endpoint === 'picstagram:feed') { + response.json({ + success: true, + data: { hasMore: false, items: picstagramPosts, nextCursor: null }, + }) + return + } + if (endpoint === 'picstagram:explore') { + response.json({ + success: true, + data: { + hasMore: false, + items: picstagramPosts.filter((post) => !post.is_owner), + nextCursor: null, + }, + }) + return + } + if (endpoint === 'picstagram:saved') { + response.json({ + success: true, + data: { + hasMore: false, + items: picstagramPosts.filter((post) => post.is_saved), + nextCursor: null, + }, + }) + return + } + if (endpoint === 'picstagram:search') { + const search = String(request.body.search ?? '').toLowerCase() + response.json({ + success: true, + data: { + posts: picstagramPosts.filter((post) => + `${post.handle} ${post.display_name} ${post.caption} ${post.location}` + .toLowerCase() + .includes(search), + ), + profiles: picstagramProfiles.filter((profile) => + `${profile.handle} ${profile.display_name}` + .toLowerCase() + .includes(search), + ), + }, + }) + return + } + if (endpoint === 'picstagram:profile') { + const profile = picstagramProfiles.find( + (item) => + item.id === request.body.profileId || + item.handle === String(request.body.handle ?? '').toLowerCase(), + ) + if (!profile) { + response.json({ success: false, error: 'profile_not_found' }) + return + } + response.json({ + success: true, + data: { + posts: { + hasMore: false, + items: picstagramPosts.filter( + (post) => post.profile_id === profile.id, + ), + nextCursor: null, + }, + profile, + }, + }) + return + } + if (endpoint === 'picstagram:update-profile') { + Object.assign(picstagramProfiles[0], { + bio: request.body.bio, + display_name: request.body.displayName, + handle: request.body.handle, + private: request.body.private, + }) + response.json({ success: true, data: picstagramProfiles[0] }) + return + } + if (endpoint === 'picstagram:publish-post') { + const media = request.body.mediaIds + .map((id) => mockMedia.find((item) => item.id === id)) + .filter((item) => item?.mediaType === 'photo') + if (!media.length) { + response.json({ success: false, error: 'invalid_media' }) + return + } + const post = { + avatar_url: picstagramProfiles[0].avatar_url, + caption: request.body.caption, + comment_count: 0, + comments_enabled: request.body.commentsEnabled, + created_at: Date.now(), + display_name: picstagramProfiles[0].display_name, + handle: picstagramProfiles[0].handle, + id: `pic-post-${Date.now()}`, + is_liked: false, + is_owner: true, + is_saved: false, + like_count: 0, + location: request.body.location, + media: media.map((item, position) => ({ + id: item.id, + position, + url: item.url, + })), + private: picstagramProfiles[0].private, + profile_id: picstagramProfiles[0].id, + verified: picstagramProfiles[0].verified, + } + picstagramPosts.unshift(post) + response.json({ success: true, data: { id: post.id } }) + return + } + if (endpoint === 'picstagram:update-post') { + const post = picstagramPosts.find((item) => item.id === request.body.id) + if (post) + Object.assign(post, { + caption: request.body.caption, + comments_enabled: request.body.commentsEnabled, + location: request.body.location, + }) + response.json({ + success: Boolean(post), + error: post ? undefined : 'post_not_found', + }) + return + } + if (endpoint === 'picstagram:set-post-status') { + if (request.body.status !== 'published') + picstagramPosts = picstagramPosts.filter( + (post) => post.id !== request.body.id, + ) + response.json({ success: true }) + return + } + if (endpoint === 'picstagram:react') { + const post = picstagramPosts.find((item) => item.id === request.body.id) + if (post) { + const key = request.body.kind === 'like' ? 'is_liked' : 'is_saved' + const changed = post[key] !== request.body.active + post[key] = request.body.active + if (changed && request.body.kind === 'like') + post.like_count += request.body.active ? 1 : -1 + } + response.json({ + success: Boolean(post), + error: post ? undefined : 'post_not_found', + }) + return + } + if (endpoint === 'picstagram:follow') { + const profile = picstagramProfiles.find( + (item) => item.id === request.body.profileId, + ) + if (!profile) { + response.json({ success: false, error: 'profile_not_found' }) + return + } + profile.follow_status = request.body.active + ? profile.private + ? 'pending' + : 'accepted' + : null + profile.is_following = profile.follow_status === 'accepted' + profile.is_requested = profile.follow_status === 'pending' + profile.locked = profile.private && !profile.is_following + response.json({ success: true, data: { status: profile.follow_status } }) + return + } + if (endpoint === 'picstagram:respond-follow') { + picstagramActivities = picstagramActivities.filter( + (activity) => + activity.kind !== 'follow_request' || + activity.profile_id !== request.body.profileId, + ) + response.json({ success: true }) + return + } + if (endpoint === 'picstagram:comments') { + response.json({ success: true, data: picstagramComments }) + return + } + if (endpoint === 'picstagram:comment') { + const comment = { + avatar_url: picstagramProfiles[0].avatar_url, + body: request.body.body, + created_at: Date.now(), + display_name: picstagramProfiles[0].display_name, + handle: picstagramProfiles[0].handle, + id: `pic-comment-${Date.now()}`, + is_owner: true, + parent_id: request.body.parentId ?? null, + profile_id: picstagramProfiles[0].id, + verified: picstagramProfiles[0].verified, + } + picstagramComments.push(comment) + response.json({ success: true, data: { id: comment.id } }) + return + } + if (endpoint === 'picstagram:remove-comment') { + picstagramComments = picstagramComments.filter( + (comment) => comment.id !== request.body.id, + ) + response.json({ success: true }) + return + } + if (endpoint === 'picstagram:stories') { + response.json({ success: true, data: picstagramStories }) + return + } + if (endpoint === 'picstagram:publish-story') { + const media = mockMedia.find((item) => item.id === request.body.mediaId) + if (!media || media.mediaType !== 'photo') { + response.json({ success: false, error: 'invalid_media' }) + return + } + const story = { + avatar_url: picstagramProfiles[0].avatar_url, + body: request.body.body, + created_at: Date.now(), + display_name: picstagramProfiles[0].display_name, + expires_at: Date.now() + 24 * 60 * 60 * 1000, + handle: picstagramProfiles[0].handle, + id: `pic-story-${Date.now()}`, + is_owner: true, + profile_id: picstagramProfiles[0].id, + seen: true, + url: media.url, + verified: picstagramProfiles[0].verified, + view_count: 0, + } + picstagramStories.unshift(story) + response.json({ success: true, data: { id: story.id } }) + return + } + if (endpoint === 'picstagram:view-story') { + const story = picstagramStories.find((item) => item.id === request.body.id) + if (story) story.seen = true + response.json({ success: Boolean(story) }) + return + } + if (endpoint === 'picstagram:story-viewers') { + response.json({ + success: true, + data: [ + { + avatar_url: picstagramProfiles[1].avatar_url, + created_at: Date.now() - 60000, + display_name: picstagramProfiles[1].display_name, + handle: picstagramProfiles[1].handle, + id: picstagramProfiles[1].id, + verified: true, + }, + ], + }) + return + } + if (endpoint === 'picstagram:remove-story') { + picstagramStories = picstagramStories.filter( + (story) => story.id !== request.body.id, + ) + response.json({ success: true }) + return + } + if (endpoint === 'picstagram:activities') { + response.json({ success: true, data: picstagramActivities }) + return + } + if (endpoint === 'picstagram:mark-activities') { + picstagramActivities.forEach((activity) => { + activity.read_at = new Date().toISOString() + }) + response.json({ success: true }) + return + } + if (endpoint === 'picstagram:block') { + const profileId = request.body.profileId + picstagramPosts = picstagramPosts.filter( + (post) => post.profile_id !== profileId, + ) + picstagramComments = picstagramComments.filter( + (comment) => comment.profile_id !== profileId, + ) + picstagramStories = picstagramStories.filter( + (story) => story.profile_id !== profileId, + ) + picstagramActivities = picstagramActivities.filter( + (activity) => activity.profile_id !== profileId, + ) + response.json({ success: true }) + return + } + if (endpoint === 'picstagram:report') { + picstagramReports.unshift({ + created_at: Date.now(), + details: request.body.details ?? '', + id: `pic-report-${Date.now()}`, + reason: request.body.reason, + reporter_display_name: picstagramProfiles[0].display_name, + reporter_handle: picstagramProfiles[0].handle, + target_id: request.body.targetId, + target_type: request.body.targetType, + }) + response.json({ success: true }) + return + } + if (endpoint === 'picstagram:admin-reports') { + response.json({ success: true, data: picstagramReports }) + return + } + if (endpoint === 'picstagram:admin-resolve-report') { + picstagramReports = picstagramReports.filter( + (report) => report.id !== request.body.id, + ) + response.json({ success: true }) + return + } + if (endpoint.startsWith('picstagram:')) { + response.json({ success: true }) + return + } if (endpoint === 'fliptok:bootstrap') { if (!flipTokAuthenticated) { response.json({ diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index d9f9f43..35ad48b 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -283,6 +283,28 @@ Config.FlipTok = { ReportAdminGroups = { "admin" }, } +Config.Picstagram = { + PageSize = 12, + CommentPageSize = 100, + MaxPostMedia = 5, + CaptionMaxLength = 800, + CommentMaxLength = 300, + BioMaxLength = 160, + LocationMaxLength = 80, + StoryTextMaxLength = 160, + StoryLifetimeSeconds = 24 * 60 * 60, + PasswordMinLength = 8, + PasswordMaxLength = 72, + PasswordPepperConvar = "sky_phone_picstagram_password_pepper", + PostsPerMinute = 6, + StoriesPerMinute = 6, + CommentsPerMinute = 20, + ReportDetailsMaxLength = 500, + ReportReasons = { "spam", "harassment", "dangerous", "illegal", "other" }, + VerifyCommand = "picstagramverify", + AdminGroups = { "admin" }, +} + Config.MapMarkers = { MaximumMarkers = 50, LabelMaxLength = 40, diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 244c088..5254a30 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -8,6 +8,14 @@ Locales["en"] = { verified = "verified", unverified = "unverified", }, + PicstagramCommand = { + usage = "Usage: /{command} <@handle> ", + noPermission = "You do not have permission to manage Picstagram verification.", + notFound = "Picstagram profile @{handle} was not found.", + updated = "Picstagram @{handle} is now {state}.", + verified = "verified", + unverified = "unverified", + }, DeviceErrors = { phone_slot_missing = "The used phone could not be identified. Make sure phones are not stacked.", phone_stacked = "Phones cannot be stacked.", @@ -102,6 +110,78 @@ Locales["en"] = { match_not_found = "This match is no longer available.", invalid_message = "Write a message before sending.", invalid_attachment = "This attachment is unavailable.", gif_provider_unconfigured = "GIF search is not configured.", gif_provider_unauthorized = "The GIF provider key is invalid.", gif_provider_rate_limited = "GIF search is busy. Try again shortly.", gif_provider_failed = "GIFs are temporarily unavailable.", rate_limited = "Slow down for a moment and try again.", default = "Flare could not complete the request.", }, }, + picstagram = { + name = "Picstagram", loading = "Loading Picstagram", home = "Home", explore = "Explore", + create = "Create", activity = "Activity", profile = "Profile", following = "Following", + city = "City", posts = "Posts", followers = "Followers", verified = "Verified profile", + editProfile = "Edit profile", saved = "Saved", photos = "Photos", emptyBio = "No bio yet.", + authTitle = "Your Picstagram profile", login = "Sign In", register = "Register", + createAccount = "Create Profile", logout = "Sign Out", signingOut = "Signing Out...", + loginBody = "Sign in to continue with your photos, stories, follows, and saved posts.", + registerBody = "Create a private Picstagram login for your photo profile.", + registrationHint = "An iFruit account is required once to create and own a Picstagram profile.", + displayName = "Name", username = "Username", password = "Password", + confirmPassword = "Confirm password", passwordsMismatch = "The passwords do not match.", + displayNamePlaceholder = "Your name", usernamePlaceholder = "username", + passwordPlaceholder = "At least 8 characters", confirmPasswordPlaceholder = "Enter password again", + signOutTitle = "Sign out of Picstagram?", + signOutBody = "Your profile and posts stay online. This phone returns to the sign-in screen.", + stories = "Stories", yourStory = "Your story", noStories = "No active stories", + storyViews = "{count} views", storyViewers = "Story viewers", seenBy = "Seen by {count}", + nextStory = "Next story", closeStory = "Close story", removeStory = "Remove story", storyRemoved = "Story removed.", + emptyFeed = "Your feed is quiet", emptyFeedBody = "Discover profiles or share the first photo.", + 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 Gallery", chooseStoryHint = "Select one photo from Gallery", + 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", + 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", + 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", + avatar = "Profile photo", chooseAvatar = "Choose profile photo", removeAvatar = "Remove profile photo", + saveProfile = "Save profile", profileSaved = "Profile updated.", cancel = "Cancel", done = "Done", + report = "Report", reportTarget = "Report {target}", reportReason = "Reason", + reportDetails = "Additional details (optional)", submitReport = "Submit report", reported = "Report submitted.", + block = "Block profile", unblock = "Unblock profile", blocked = "Profile blocked.", + blockTitle = "Block @{handle}?", blockBody = "You will no longer see or interact with each other's Picstagram content.", + moderation = "Moderation", reports = "Open reports", noReports = "No open reports", + 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." }, + 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.", + comments_disabled = "Comments are disabled.", invalid_profile = "Check your profile details.", + invalid_handle = "Use 3–24 letters, numbers, dots, or underscores.", + invalid_display_name = "Enter a display name.", invalid_password = "Password must be 8–72 characters.", + invalid_credentials = "Username or password is incorrect.", + already_registered = "This iFruit account already owns a Picstagram profile.", + handle_taken = "This username is already taken.", post_not_found = "This post is unavailable.", + story_not_found = "This story is unavailable.", profile_not_found = "This profile is unavailable.", + request_not_found = "This follow request is no longer open.", profile_unavailable = "This profile is unavailable.", + blocked = "This profile is blocked.", already_reported = "You already reported this content.", + invalid_report = "Choose a valid report reason.", invalid_search = "Enter a valid search.", + invalid_cursor = "The feed changed. Refresh and try again.", invalid_status = "This action is not available.", + not_authorized = "You are not allowed to do that.", report_not_found = "This report is no longer open.", + rate_limited = "Too many actions. Try again shortly.", not_authenticated = "Sign in to iFruit first.", + picstagram_not_authenticated = "Sign in to Picstagram first.", request_failed = "The request failed.", + default = "Picstagram could not complete the request." + }, + }, fliptok = { name = "FlipTok", loading = "Loading FlipTok", following = "Following", forYou = "For You", verified = "Verified account", originalSound = "original sound", save = "Save", home = "Home", discover = "Discover", create = "Create", activity = "Activity", profile = "Profile", diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index f1dd2e2..64fa820 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -59,6 +59,7 @@ server_scripts { 'source/server/marketplace.lua', 'source/server/pages.lua', 'source/server/fliptok.lua', + 'source/server/picstagram.lua', 'source/server/map.lua', 'source/server/calendar.lua', 'source/server/radio.lua', diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index 3aad653..586d795 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -82,6 +82,36 @@ local server_callbacks = { "fliptok:admin-resolve-report", "fliptok:block", "fliptok:delete", + "picstagram:register", + "picstagram:login", + "picstagram:logout", + "picstagram:bootstrap", + "picstagram:feed", + "picstagram:explore", + "picstagram:search", + "picstagram:saved", + "picstagram:profile", + "picstagram:update-profile", + "picstagram:publish-post", + "picstagram:update-post", + "picstagram:set-post-status", + "picstagram:react", + "picstagram:follow", + "picstagram:respond-follow", + "picstagram:comments", + "picstagram:comment", + "picstagram:remove-comment", + "picstagram:publish-story", + "picstagram:stories", + "picstagram:view-story", + "picstagram:story-viewers", + "picstagram:remove-story", + "picstagram:activities", + "picstagram:mark-activities", + "picstagram:block", + "picstagram:report", + "picstagram:admin-reports", + "picstagram:admin-resolve-report", "calendar:list", "calendar:create", "calendar:update", @@ -450,6 +480,22 @@ RegisterNetEvent("sky_phone:fliptok:new", function(data) SendNUIMessage({ type = "fliptok:new", data = data }) end) +RegisterNetEvent("sky_phone:picstagram:command-feedback", function(data) + Bridge.Framework.Notify("Picstagram", data.message, data.notificationType, 5000) +end) + +RegisterNetEvent("sky_phone:picstagram:verification-changed", function(data) + SendNUIMessage({ type = "picstagram:verification-changed", data = data }) +end) + +RegisterNetEvent("sky_phone:picstagram:new", function(data) + local picstagram_locale = get_locale().Nui.Apps.picstagram + local notification_text = picstagram_locale.notifications[data.kind] or picstagram_locale.notifications.default + data.title = picstagram_locale.name + data.text = notification_text:gsub("{actor}", tostring(data.actor or "")) + SendNUIMessage({ type = "picstagram:new", data = data }) +end) + RegisterNetEvent("sky_phone:marketplace:new-message", function(data) local marketplace_locale = get_locale().Nui.Apps.citymarkt data.title = marketplace_locale.name diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index aa61d1f..70ea649 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -908,6 +908,299 @@ local schema = { }, tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", }, + { + name = "sky_phone_picstagram_profiles", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "account_id", type = "BIGINT UNSIGNED NOT NULL" }, + { name = "handle", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_general_ci" }, + { name = "display_name", type = "VARCHAR(40) NOT NULL" }, + { name = "bio", type = "VARCHAR(160) NOT NULL DEFAULT ''" }, + { name = "avatar_media_id", type = "BIGINT UNSIGNED NULL" }, + { name = "private", type = "TINYINT(1) NOT NULL DEFAULT 0" }, + { name = "verified", type = "TINYINT(1) NOT NULL DEFAULT 0" }, + { name = "status", type = "ENUM('active', 'hidden', 'removed') NOT NULL DEFAULT 'active'" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_picstagram_account", columns = "(`account_id`)" }, + { name = "uniq_sky_phone_picstagram_handle", columns = "(`handle`)" }, + }, + foreignKeys = { + { column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" }, + { column = "avatar_media_id", references = "`sky_phone_media` (`id`) ON DELETE SET NULL" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_picstagram_credentials", + columns = { + { name = "profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "password_hash", type = "BINARY(32) NOT NULL" }, + { name = "password_salt", type = "CHAR(32) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }, + }, + primaryKey = "profile_id", + foreignKeys = { + { 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_sessions", + columns = { + { name = "device_imei", type = "CHAR(15) 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" }, + { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }, + }, + primaryKey = "device_imei", + indexes = { + { name = "idx_sky_phone_picstagram_sessions_profile", columns = "(`profile_id`, `updated_at`)" }, + }, + foreignKeys = { + { column = "device_imei", references = "`sky_phone_devices` (`imei`) 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_posts", + columns = { + { name = "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 = "caption", type = "VARCHAR(800) NOT NULL DEFAULT ''" }, + { name = "location", type = "VARCHAR(80) NOT NULL DEFAULT ''" }, + { name = "comments_enabled", type = "TINYINT(1) NOT NULL DEFAULT 1" }, + { name = "status", type = "ENUM('published', 'archived', 'hidden', 'removed') NOT NULL DEFAULT 'published'" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + indexes = { + { name = "idx_sky_phone_picstagram_feed", columns = "(`status`, `created_at`, `id`)" }, + { name = "idx_sky_phone_picstagram_profile_posts", columns = "(`profile_id`, `status`, `created_at`)" }, + }, + foreignKeys = { + { 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_post_media", + columns = { + { name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" }, + { name = "post_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "media_id", type = "BIGINT UNSIGNED NOT NULL" }, + { name = "position", type = "TINYINT UNSIGNED NOT NULL" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_picstagram_post_position", columns = "(`post_id`, `position`)" }, + { name = "uniq_sky_phone_picstagram_post_media", columns = "(`post_id`, `media_id`)" }, + }, + foreignKeys = { + { column = "post_id", references = "`sky_phone_picstagram_posts` (`id`) ON DELETE CASCADE" }, + { column = "media_id", references = "`sky_phone_media` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_picstagram_reactions", + columns = { + { name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" }, + { name = "post_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 = "kind", type = "ENUM('like', 'save') NOT NULL" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_picstagram_reaction", columns = "(`post_id`, `profile_id`, `kind`)" }, + }, + indexes = { + { name = "idx_sky_phone_picstagram_saved", columns = "(`profile_id`, `kind`, `created_at`)" }, + }, + foreignKeys = { + { column = "post_id", references = "`sky_phone_picstagram_posts` (`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_follows", + columns = { + { name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" }, + { name = "follower_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "following_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "status", type = "ENUM('pending', 'accepted') NOT NULL" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_picstagram_follow", columns = "(`follower_id`, `following_id`)" }, + }, + indexes = { + { name = "idx_sky_phone_picstagram_following", columns = "(`following_id`, `status`, `created_at`)" }, + }, + foreignKeys = { + { column = "follower_id", references = "`sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE" }, + { column = "following_id", references = "`sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_picstagram_comments", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "post_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 = "parent_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "body", type = "VARCHAR(300) NOT NULL" }, + { name = "status", type = "ENUM('visible', 'removed') NOT NULL DEFAULT 'visible'" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + indexes = { + { name = "idx_sky_phone_picstagram_comments", columns = "(`post_id`, `status`, `created_at`)" }, + }, + foreignKeys = { + { column = "post_id", references = "`sky_phone_picstagram_posts` (`id`) ON DELETE CASCADE" }, + { column = "profile_id", references = "`sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE" }, + { column = "parent_id", references = "`sky_phone_picstagram_comments` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_picstagram_stories", + columns = { + { name = "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 = "media_id", type = "BIGINT UNSIGNED NOT NULL" }, + { name = "body", type = "VARCHAR(160) NOT NULL DEFAULT ''" }, + { name = "status", type = "ENUM('active', 'removed') NOT NULL DEFAULT 'active'" }, + { name = "expires_at", type = "DATETIME NOT NULL" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + indexes = { + { name = "idx_sky_phone_picstagram_stories", columns = "(`profile_id`, `status`, `expires_at`)" }, + { name = "idx_sky_phone_picstagram_story_expiry", columns = "(`status`, `expires_at`)" }, + }, + foreignKeys = { + { column = "profile_id", references = "`sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE" }, + { column = "media_id", references = "`sky_phone_media` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_picstagram_story_views", + columns = { + { name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" }, + { name = "story_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_story_view", columns = "(`story_id`, `profile_id`)" }, + }, + foreignKeys = { + { column = "story_id", references = "`sky_phone_picstagram_stories` (`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_activities", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { 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 = "read_at", type = "DATETIME NULL" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + indexes = { + { name = "idx_sky_phone_picstagram_activity", columns = "(`recipient_id`, `read_at`, `created_at`)" }, + }, + foreignKeys = { + { column = "recipient_id", references = "`sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE" }, + { column = "actor_id", references = "`sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE" }, + { column = "post_id", references = "`sky_phone_picstagram_posts` (`id`) ON DELETE SET NULL" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_picstagram_reports", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "reporter_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "target_type", type = "ENUM('profile', 'post', 'story', 'comment') NOT NULL" }, + { name = "target_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "reason", type = "ENUM('spam', 'harassment', 'dangerous', 'illegal', 'other') NOT NULL" }, + { name = "details", type = "VARCHAR(500) NOT NULL DEFAULT ''" }, + { name = "status", type = "ENUM('open', 'reviewed', 'dismissed') NOT NULL DEFAULT 'open'" }, + { name = "resolved_action", type = "VARCHAR(16) NULL", characterSet = "ascii", collation = "ascii_general_ci" }, + { name = "resolved_at", type = "DATETIME NULL" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_picstagram_report", columns = "(`reporter_id`, `target_type`, `target_id`)" }, + }, + indexes = { + { name = "idx_sky_phone_picstagram_reports", columns = "(`status`, `created_at`)" }, + }, + foreignKeys = { + { column = "reporter_id", references = "`sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_picstagram_blocks", + columns = { + { name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" }, + { name = "blocker_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "blocked_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_block", columns = "(`blocker_id`, `blocked_id`)" }, + }, + foreignKeys = { + { column = "blocker_id", references = "`sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE" }, + { column = "blocked_id", references = "`sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_picstagram_moderation_audit", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "report_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "moderator_identifier", type = "VARCHAR(80) NOT NULL" }, + { name = "action", type = "VARCHAR(16) NOT NULL", characterSet = "ascii", collation = "ascii_general_ci" }, + { name = "target_type", type = "VARCHAR(16) NOT NULL", characterSet = "ascii", collation = "ascii_general_ci" }, + { name = "target_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + indexes = { + { name = "idx_sky_phone_picstagram_audit_target", columns = "(`target_type`, `target_id`, `created_at`)" }, + }, + foreignKeys = { + { column = "report_id", references = "`sky_phone_picstagram_reports` (`id`) ON DELETE SET NULL" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, { name = "sky_phone_fliptok_profiles", columns = { diff --git a/sky_phone/source/server/picstagram.lua b/sky_phone/source/server/picstagram.lua new file mode 100644 index 0000000..683dc34 --- /dev/null +++ b/sky_phone/source/server/picstagram.lua @@ -0,0 +1,1351 @@ +Bridge.Database.AfterMigration("sky_phone", function() +local report_reasons = {} +local password_pepper = GetConvar(Config.Picstagram.PasswordPepperConvar, "") + +if password_pepper == "" then + Bridge.Debug( + "warn", + "[sky_phone] Picstagram password pepper convar '%s' is empty; configure it before production use.", + Config.Picstagram.PasswordPepperConvar, + { always = true } + ) +end + +for index = 1, #Config.Picstagram.ReportReasons do + report_reasons[Config.Picstagram.ReportReasons[index]] = true +end + +local function trim(value) + if type(value) ~= "string" then + return nil + end + return value:match("^%s*(.-)%s*$") +end + +local function valid_text(value, minimum, maximum) + local length = type(value) == "string" and utf8.len(value) or nil + return length and length >= minimum and length <= maximum +end + +local function affected_rows(result) + if type(result) == "number" then + return result + end + if type(result) == "table" then + return tonumber(result.affectedRows) or tonumber(result.affected_rows) or 0 + end + return 0 +end + +local function new_id() + local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {}) + if not rows[1] or type(rows[1].id) ~= "string" then + error("[sky_phone] Database did not generate a Picstagram id.") + end + return rows[1].id +end + +local function valid_id(value) + return type(value) == "string" and #value == 36 and value:match("^[0-9a-fA-F%-]+$") ~= nil +end + +local function normalize_handle(value) + local handle = trim(value) + if not handle then + return nil + end + handle = handle:lower():gsub("^@", "") + if #handle < 3 + or #handle > 24 + or not handle:match("^[a-z0-9][a-z0-9._]*[a-z0-9]$") + or handle:find("..", 1, true) + then + return nil + end + return handle +end + +local function valid_password(value) + local length = type(value) == "string" and utf8.len(value) or nil + return length + and length >= Config.Picstagram.PasswordMinLength + and length <= Config.Picstagram.PasswordMaxLength +end + +local function are_profiles_blocked(first_id, second_id) + local rows = Bridge.Database.Query([[ + SELECT `id` FROM `sky_phone_picstagram_blocks` + WHERE (`blocker_id` = ? AND `blocked_id` = ?) + OR (`blocker_id` = ? AND `blocked_id` = ?) + LIMIT 1 + ]], { first_id, second_id, second_id, first_id }) + return rows[1] ~= nil +end + +local function profile_for_session(source) + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return nil, error_response + end + local rows = Bridge.Database.Query([[ + SELECT p.* FROM `sky_phone_picstagram_sessions` s + JOIN `sky_phone_picstagram_profiles` p ON p.`id` = s.`profile_id` + WHERE s.`device_imei` = ? LIMIT 1 + ]], { session.imei }) + if not rows[1] then + return nil, { success = false, error = "picstagram_not_authenticated" } + end + if rows[1].status ~= "active" then + return nil, { success = false, error = "profile_unavailable" } + end + return rows[1] +end + +local function hydrate_profile(profile, viewer_id) + profile.private = tonumber(profile.private) == 1 + profile.verified = tonumber(profile.verified) == 1 + profile.is_owner = viewer_id == profile.id + profile.followers = tonumber(profile.followers) or 0 + profile.following = tonumber(profile.following) or 0 + profile.post_count = tonumber(profile.post_count) or 0 + profile.avatar_media_id = tonumber(profile.avatar_media_id) + profile.follow_status = profile.follow_status ~= "" and profile.follow_status or nil + profile.is_following = profile.follow_status == "accepted" + profile.is_requested = profile.follow_status == "pending" + profile.locked = profile.private and not profile.is_owner and not profile.is_following + return profile +end + +local function load_profile(profile_id, viewer_id) + local rows = Bridge.Database.Query([[ + SELECT p.`id`, p.`handle`, p.`display_name`, p.`bio`, p.`avatar_media_id`, p.`private`, p.`verified`, p.`status`, + avatar.`url` AS `avatar_url`, + COALESCE((SELECT f.`status` FROM `sky_phone_picstagram_follows` f + WHERE f.`follower_id` = ? AND f.`following_id` = p.`id` LIMIT 1), '') AS `follow_status`, + (SELECT COUNT(*) FROM `sky_phone_picstagram_follows` f + WHERE f.`following_id` = p.`id` AND f.`status` = 'accepted') AS `followers`, + (SELECT COUNT(*) FROM `sky_phone_picstagram_follows` f + WHERE f.`follower_id` = p.`id` AND f.`status` = 'accepted') AS `following`, + (SELECT COUNT(*) FROM `sky_phone_picstagram_posts` post + WHERE post.`profile_id` = p.`id` AND post.`status` = 'published') AS `post_count` + FROM `sky_phone_picstagram_profiles` p + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id` + WHERE p.`id` = ? AND p.`status` = 'active' + AND NOT EXISTS(SELECT 1 FROM `sky_phone_picstagram_blocks` b + WHERE (b.`blocker_id` = ? AND b.`blocked_id` = p.`id`) + OR (b.`blocker_id` = p.`id` AND b.`blocked_id` = ?)) + LIMIT 1 + ]], { viewer_id, profile_id, viewer_id, viewer_id }) + return rows[1] and hydrate_profile(rows[1], viewer_id) or nil +end + +local function load_post_media(posts) + if #posts == 0 then + return posts + end + local placeholders = {} + local values = {} + local by_id = {} + for index = 1, #posts do + local post = posts[index] + placeholders[index] = "?" + values[index] = post.id + post.media = {} + by_id[post.id] = post + end + local rows = Bridge.Database.Query(([[ + SELECT pm.`post_id`, pm.`position`, m.`id`, m.`url` + FROM `sky_phone_picstagram_post_media` pm + JOIN `sky_phone_media` m ON m.`id` = pm.`media_id` + WHERE pm.`post_id` IN (%s) + ORDER BY pm.`post_id`, pm.`position` + ]]):format(table.concat(placeholders, ", ")), values) + for index = 1, #rows do + local media = rows[index] + local post = by_id[media.post_id] + if post then + post.media[#post.media + 1] = { + id = tonumber(media.id), + position = tonumber(media.position), + url = media.url, + } + end + end + return posts +end + +local function hydrate_posts(rows) + for index = 1, #rows do + local post = rows[index] + post.comments_enabled = tonumber(post.comments_enabled) == 1 + post.is_owner = tonumber(post.is_owner) == 1 + post.is_liked = tonumber(post.is_liked) == 1 + post.is_saved = tonumber(post.is_saved) == 1 + post.verified = tonumber(post.verified) == 1 + post.private = tonumber(post.private) == 1 + post.like_count = tonumber(post.like_count) or 0 + post.comment_count = tonumber(post.comment_count) or 0 + post.created_at = (tonumber(post.created_at_unix) or 0) * 1000 + post.created_at_unix = nil + end + return load_post_media(rows) +end + +local function parse_cursor(value) + if value == nil or value == "" then + return nil, nil + end + if type(value) ~= "string" or #value > 64 then + return false, false + end + local timestamp, id = value:match("^(%d+):([%x%-]+)$") + if not timestamp or not valid_id(id) then + return false, false + end + return tonumber(timestamp), id +end + +local function list_posts(viewer_id, where_clause, values, cursor_value) + local cursor_time, cursor_id = parse_cursor(cursor_value) + if cursor_time == false then + return nil, "invalid_cursor" + end + local parameters = { viewer_id, viewer_id, viewer_id } + for index = 1, #values do + parameters[#parameters + 1] = values[index] + end + local cursor_clause = "" + if cursor_time then + cursor_clause = " AND (post.`created_at` < FROM_UNIXTIME(?) OR (post.`created_at` = FROM_UNIXTIME(?) AND post.`id` < ?))" + parameters[#parameters + 1] = cursor_time + parameters[#parameters + 1] = cursor_time + parameters[#parameters + 1] = cursor_id + end + parameters[#parameters + 1] = viewer_id + parameters[#parameters + 1] = viewer_id + parameters[#parameters + 1] = Config.Picstagram.PageSize + 1 + local rows = hydrate_posts(Bridge.Database.Query(([[ + SELECT post.`id`, post.`profile_id`, post.`caption`, post.`location`, post.`comments_enabled`, + UNIX_TIMESTAMP(post.`created_at`) AS `created_at_unix`, profile.`handle`, profile.`display_name`, + profile.`verified`, profile.`private`, avatar.`url` AS `avatar_url`, + (post.`profile_id` = ?) AS `is_owner`, + EXISTS(SELECT 1 FROM `sky_phone_picstagram_reactions` reaction + WHERE reaction.`post_id` = post.`id` AND reaction.`profile_id` = ? AND reaction.`kind` = 'like') AS `is_liked`, + EXISTS(SELECT 1 FROM `sky_phone_picstagram_reactions` reaction + WHERE reaction.`post_id` = post.`id` AND reaction.`profile_id` = ? AND reaction.`kind` = 'save') AS `is_saved`, + (SELECT COUNT(*) FROM `sky_phone_picstagram_reactions` reaction + WHERE reaction.`post_id` = post.`id` AND reaction.`kind` = 'like') AS `like_count`, + (SELECT COUNT(*) FROM `sky_phone_picstagram_comments` comment + WHERE comment.`post_id` = post.`id` AND comment.`status` = 'visible') AS `comment_count` + FROM `sky_phone_picstagram_posts` post + JOIN `sky_phone_picstagram_profiles` profile ON profile.`id` = post.`profile_id` + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = profile.`avatar_media_id` + WHERE post.`status` = 'published' AND profile.`status` = 'active' AND %s%s + AND NOT EXISTS(SELECT 1 FROM `sky_phone_picstagram_blocks` block + WHERE (block.`blocker_id` = ? AND block.`blocked_id` = post.`profile_id`) + OR (block.`blocked_id` = ? AND block.`blocker_id` = post.`profile_id`)) + ORDER BY post.`created_at` DESC, post.`id` DESC + LIMIT ? + ]]):format(where_clause, cursor_clause), parameters)) + local has_more = #rows > Config.Picstagram.PageSize + if has_more then + rows[#rows] = nil + end + local last = rows[#rows] + local next_cursor = last and ((math.floor(last.created_at / 1000)) .. ":" .. last.id) or nil + return { items = rows, hasMore = has_more, nextCursor = next_cursor } +end + +local function find_accessible_post(post_id, viewer_id) + local rows = Bridge.Database.Query([[ + SELECT post.`id`, post.`profile_id`, post.`comments_enabled`, profile.`account_id` + FROM `sky_phone_picstagram_posts` post + JOIN `sky_phone_picstagram_profiles` profile ON profile.`id` = post.`profile_id` + WHERE post.`id` = ? AND post.`status` = 'published' AND profile.`status` = 'active' + AND (profile.`private` = 0 OR post.`profile_id` = ? OR EXISTS( + SELECT 1 FROM `sky_phone_picstagram_follows` follow + WHERE follow.`follower_id` = ? AND follow.`following_id` = post.`profile_id` + AND follow.`status` = 'accepted')) + AND NOT EXISTS(SELECT 1 FROM `sky_phone_picstagram_blocks` block + WHERE (block.`blocker_id` = ? AND block.`blocked_id` = post.`profile_id`) + OR (block.`blocked_id` = ? AND block.`blocker_id` = post.`profile_id`)) + LIMIT 1 + ]], { post_id, viewer_id, viewer_id, viewer_id, viewer_id }) + return rows[1] +end + +local function create_activity(recipient_id, actor_id, kind, post_id) + if recipient_id == actor_id then + return + end + local id = new_id() + if post_id then + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_activities` + (`id`, `recipient_id`, `actor_id`, `post_id`, `kind`) + VALUES (?, ?, ?, ?, ?) + ]], { id, recipient_id, actor_id, post_id, kind }) + else + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_activities` + (`id`, `recipient_id`, `actor_id`, `kind`) + VALUES (?, ?, ?, ?) + ]], { id, recipient_id, actor_id, kind }) + end + local rows = Bridge.Database.Query([[ + SELECT recipient.`account_id`, actor.`display_name` AS `actor_name` + FROM `sky_phone_picstagram_profiles` recipient + JOIN `sky_phone_picstagram_profiles` actor ON actor.`id` = ? + WHERE recipient.`id` = ? LIMIT 1 + ]], { actor_id, recipient_id }) + if not rows[1] then + error("[sky_phone] Picstagram activity references a missing profile.") + end + SkyPhone.NotifyAccountDevices(tonumber(rows[1].account_id), "sky_phone:picstagram:new", { + actor = rows[1].actor_name, + kind = kind, + postId = post_id, + }) +end + +local function is_admin(source) + return Bridge.Framework.HasAdminGroup(source, Config.Picstagram.AdminGroups) +end + +Bridge.Callbacks.Register("sky_phone:picstagram:register", function(source, data) + if not SkyPhone.AllowOperation(source, "picstagram:register", 5, 60) then + return { success = false, error = "rate_limited" } + end + local account, error_response = SkyPhone.RequireAccount(source) + if not account then + return error_response + end + if type(data) ~= "table" then + return { success = false, error = "invalid_request" } + end + local handle = normalize_handle(data.handle) + local display_name = trim(data.displayName) + if not handle then + return { success = false, error = "invalid_handle" } + end + if not valid_text(display_name, 1, 40) then + return { success = false, error = "invalid_display_name" } + end + if not valid_password(data.password) then + return { success = false, error = "invalid_password" } + end + if Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_picstagram_profiles` WHERE `account_id` = ? LIMIT 1", + { account.id } + )[1] then + return { success = false, error = "already_registered" } + end + if Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_picstagram_profiles` WHERE `handle` = ? LIMIT 1", + { handle } + )[1] then + return { success = false, error = "handle_taken" } + end + local entropy = Bridge.Database.Query("SELECT UUID() AS `id`, REPLACE(UUID(), '-', '') AS `salt`", {})[1] + if not entropy or not valid_id(entropy.id) or type(entropy.salt) ~= "string" then + error("[sky_phone] Database did not generate Picstagram registration entropy.") + end + if not Bridge.Database.Transaction({ + { + query = [[INSERT INTO `sky_phone_picstagram_profiles` + (`id`, `account_id`, `handle`, `display_name`) VALUES (?, ?, ?, ?)]], + params = { entropy.id, account.id, handle, display_name }, + }, + { + query = [[INSERT INTO `sky_phone_picstagram_credentials` + (`profile_id`, `password_hash`, `password_salt`) + VALUES (?, UNHEX(SHA2(CONCAT(?, ?, ?), 256)), ?)]], + params = { entropy.id, password_pepper, entropy.salt, data.password, entropy.salt }, + }, + { + query = [[INSERT INTO `sky_phone_picstagram_sessions` (`device_imei`, `profile_id`) + VALUES (?, ?) ON DUPLICATE KEY UPDATE `profile_id` = VALUES(`profile_id`), `updated_at` = CURRENT_TIMESTAMP]], + params = { account.imei, entropy.id }, + }, + }) then + return { success = false, error = "request_failed" } + end + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:login", function(source, data) + if not SkyPhone.AllowOperation(source, "picstagram:login", 10, 60) then + return { success = false, error = "rate_limited" } + end + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return error_response + end + if type(data) ~= "table" then + return { success = false, error = "invalid_credentials" } + end + local handle = normalize_handle(data.handle) + if not handle or not valid_password(data.password) then + return { success = false, error = "invalid_credentials" } + end + local profiles = Bridge.Database.Query([[ + SELECT profile.`id` FROM `sky_phone_picstagram_profiles` profile + JOIN `sky_phone_picstagram_credentials` credentials ON credentials.`profile_id` = profile.`id` + WHERE profile.`handle` = ? AND profile.`status` = 'active' + AND credentials.`password_hash` = UNHEX(SHA2(CONCAT(?, credentials.`password_salt`, ?), 256)) + LIMIT 1 + ]], { handle, password_pepper, data.password }) + if not profiles[1] then + return { success = false, error = "invalid_credentials" } + end + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_sessions` (`device_imei`, `profile_id`) + VALUES (?, ?) ON DUPLICATE KEY UPDATE `profile_id` = VALUES(`profile_id`), `updated_at` = CURRENT_TIMESTAMP + ]], { session.imei, profiles[1].id }) + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:logout", function(source) + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return error_response + end + Bridge.Database.Query("DELETE FROM `sky_phone_picstagram_sessions` WHERE `device_imei` = ?", { session.imei }) + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:bootstrap", function(source) + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return error_response + end + local profile = profile_for_session(source) + if not profile then + return { success = true, data = { authenticated = false, isAdmin = is_admin(source) } } + end + local hydrated = load_profile(profile.id, profile.id) + if not hydrated then + return { success = false, error = "profile_unavailable" } + end + local feed, feed_error = list_posts( + profile.id, + [[post.`profile_id` = ? OR EXISTS(SELECT 1 FROM `sky_phone_picstagram_follows` follow + WHERE follow.`follower_id` = ? AND follow.`following_id` = post.`profile_id` + AND follow.`status` = 'accepted')]], + { profile.id, profile.id }, + nil + ) + if not feed then + return { success = false, error = feed_error } + end + return { + success = true, + data = { + authenticated = true, + feed = feed, + isAdmin = is_admin(source), + profile = hydrated, + }, + } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:feed", function(source, data) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + data = type(data) == "table" and data or {} + local result, list_error = list_posts( + profile.id, + [[post.`profile_id` = ? OR EXISTS(SELECT 1 FROM `sky_phone_picstagram_follows` follow + WHERE follow.`follower_id` = ? AND follow.`following_id` = post.`profile_id` + AND follow.`status` = 'accepted')]], + { profile.id, profile.id }, + data.cursor + ) + return result and { success = true, data = result } or { success = false, error = list_error } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:explore", function(source, data) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + data = type(data) == "table" and data or {} + local result, list_error = list_posts(profile.id, "profile.`private` = 0", {}, data.cursor) + return result and { success = true, data = result } or { success = false, error = list_error } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:search", function(source, data) + if not SkyPhone.AllowOperation(source, "picstagram:search", 40, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + local search = trim(type(data) == "table" and data.search or nil) + if not search or not valid_text(search, 1, 80) then + return { success = false, error = "invalid_search" } + end + local pattern = "%" .. search:lower():gsub("^[@#]", "") .. "%" + local profiles = Bridge.Database.Query([[ + SELECT candidate.`id`, candidate.`handle`, candidate.`display_name`, candidate.`bio`, candidate.`avatar_media_id`, candidate.`private`, candidate.`status`, + candidate.`verified`, avatar.`url` AS `avatar_url`, + COALESCE((SELECT f.`status` FROM `sky_phone_picstagram_follows` f + WHERE f.`follower_id` = ? AND f.`following_id` = candidate.`id` LIMIT 1), '') AS `follow_status`, + (SELECT COUNT(*) FROM `sky_phone_picstagram_follows` f + WHERE f.`following_id` = candidate.`id` AND f.`status` = 'accepted') AS `followers`, + (SELECT COUNT(*) FROM `sky_phone_picstagram_follows` f + WHERE f.`follower_id` = candidate.`id` AND f.`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_profiles` candidate + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = candidate.`avatar_media_id` + WHERE candidate.`status` = 'active' + AND (LOWER(candidate.`handle`) LIKE ? OR LOWER(candidate.`display_name`) LIKE ?) + 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.`verified` DESC, candidate.`handle` LIMIT 20 + ]], { profile.id, pattern, pattern, profile.id, profile.id }) + for index = 1, #profiles do + hydrate_profile(profiles[index], profile.id) + end + local posts = list_posts( + profile.id, + "profile.`private` = 0 AND (LOWER(post.`caption`) LIKE ? OR LOWER(post.`location`) LIKE ?)", + { pattern, pattern }, + nil + ) + return { success = true, data = { profiles = profiles, posts = posts and posts.items or {} } } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:saved", function(source, data) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + data = type(data) == "table" and data or {} + local result, list_error = list_posts( + profile.id, + [[EXISTS(SELECT 1 FROM `sky_phone_picstagram_reactions` saved + WHERE saved.`post_id` = post.`id` AND saved.`profile_id` = ? AND saved.`kind` = 'save')]], + { profile.id }, + data.cursor + ) + return result and { success = true, data = result } or { success = false, error = list_error } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:profile", function(source, data) + local viewer, error_response = profile_for_session(source) + if not viewer then + return error_response + end + data = type(data) == "table" and data or {} + local target_id = valid_id(data.profileId) and data.profileId or nil + if not target_id and type(data.handle) == "string" then + local handle = normalize_handle(data.handle) + local rows = handle and Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_picstagram_profiles` WHERE `handle` = ? LIMIT 1", + { handle } + ) or {} + target_id = rows[1] and rows[1].id or nil + end + if not target_id then + return { success = false, error = "profile_not_found" } + end + local target = load_profile(target_id, viewer.id) + if not target then + return { success = false, error = "profile_not_found" } + end + local posts = { items = {}, hasMore = false, nextCursor = nil } + if not target.locked then + local list_error + posts, list_error = list_posts(viewer.id, "post.`profile_id` = ?", { target.id }, data.cursor) + if not posts then + return { success = false, error = list_error } + end + end + return { success = true, data = { profile = target, posts = posts } } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:update-profile", function(source, data) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" or type(data.private) ~= "boolean" then + return { success = false, error = "invalid_profile" } + end + local handle = normalize_handle(data.handle) + local display_name = trim(data.displayName) + local bio = trim(data.bio) or "" + if not handle or not valid_text(display_name, 1, 40) or not valid_text(bio, 0, Config.Picstagram.BioMaxLength) then + return { success = false, error = "invalid_profile" } + end + local avatar_media_id = tonumber(data.avatarMediaId) or 0 + if avatar_media_id ~= math.floor(avatar_media_id) or avatar_media_id < 0 then + return { success = false, error = "invalid_media" } + end + if avatar_media_id > 0 and not SkyPhoneMedia.ResolveOwnedMedia(source, tostring(avatar_media_id), "photo") then + return { success = false, error = "invalid_media" } + end + if Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_picstagram_profiles` WHERE `handle` = ? AND `id` <> ? LIMIT 1", + { handle, profile.id } + )[1] then + return { success = false, error = "handle_taken" } + end + Bridge.Database.Query([[ + UPDATE `sky_phone_picstagram_profiles` + SET `handle` = ?, `display_name` = ?, `bio` = ?, `private` = ?, `avatar_media_id` = NULLIF(?, 0) + WHERE `id` = ? + ]], { handle, display_name, bio, data.private and 1 or 0, avatar_media_id, profile.id }) + return { success = true, data = load_profile(profile.id, profile.id) } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:publish-post", function(source, data) + if not SkyPhone.AllowOperation(source, "picstagram:publish", Config.Picstagram.PostsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" or type(data.mediaIds) ~= "table" or type(data.commentsEnabled) ~= "boolean" then + return { success = false, error = "invalid_post" } + end + local caption = trim(data.caption) or "" + local location = trim(data.location) or "" + if not valid_text(caption, 0, Config.Picstagram.CaptionMaxLength) + or not valid_text(location, 0, Config.Picstagram.LocationMaxLength) + or #data.mediaIds < 1 + or #data.mediaIds > Config.Picstagram.MaxPostMedia + then + return { success = false, error = "invalid_post" } + end + local media_ids = {} + local seen = {} + for index = 1, #data.mediaIds do + local media_id = tonumber(data.mediaIds[index]) + 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 + return { success = false, error = "invalid_media" } + end + seen[media_id] = true + media_ids[index] = media_id + end + local id = new_id() + local statements = { + { + query = [[INSERT INTO `sky_phone_picstagram_posts` + (`id`, `profile_id`, `caption`, `location`, `comments_enabled`) + VALUES (?, ?, ?, ?, ?)]], + params = { id, profile.id, caption, location, data.commentsEnabled == false and 0 or 1 }, + }, + } + for index = 1, #media_ids do + statements[#statements + 1] = { + query = [[INSERT INTO `sky_phone_picstagram_post_media` + (`post_id`, `media_id`, `position`) VALUES (?, ?, ?)]], + params = { id, media_ids[index], index }, + } + end + if not Bridge.Database.Transaction(statements) then + return { success = false, error = "request_failed" } + end + return { success = true, data = { id = id } } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:update-post", function(source, data) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" or not valid_id(data.id) or type(data.commentsEnabled) ~= "boolean" then + return { success = false, error = "invalid_post" } + end + local caption = trim(data.caption) or "" + local location = trim(data.location) or "" + if not valid_text(caption, 0, Config.Picstagram.CaptionMaxLength) + or not valid_text(location, 0, Config.Picstagram.LocationMaxLength) + then + return { success = false, error = "invalid_post" } + end + local result = Bridge.Database.Query([[ + UPDATE `sky_phone_picstagram_posts` + SET `caption` = ?, `location` = ?, `comments_enabled` = ? + WHERE `id` = ? AND `profile_id` = ? AND `status` IN ('published', 'archived') + ]], { caption, location, data.commentsEnabled == false and 0 or 1, data.id, profile.id }) + return affected_rows(result) > 0 + and { success = true } + or { success = false, error = "post_not_found" } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:set-post-status", function(source, data) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" or not valid_id(data.id) then + return { success = false, error = "invalid_post" } + end + local target = data.status + local current = Bridge.Database.Query( + "SELECT `status` FROM `sky_phone_picstagram_posts` WHERE `id` = ? AND `profile_id` = ? LIMIT 1", + { data.id, profile.id } + )[1] + if not current then + return { success = false, error = "post_not_found" } + end + local allowed = (current.status == "published" and (target == "archived" or target == "removed")) + or (current.status == "archived" and (target == "published" or target == "removed")) + if not allowed then + return { success = false, error = "invalid_status" } + end + Bridge.Database.Query( + "UPDATE `sky_phone_picstagram_posts` SET `status` = ? WHERE `id` = ? AND `profile_id` = ?", + { target, data.id, profile.id } + ) + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:react", function(source, data) + if not SkyPhone.AllowOperation(source, "picstagram:react", 80, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" + or not valid_id(data.id) + or (data.kind ~= "like" and data.kind ~= "save") + or type(data.active) ~= "boolean" + then + return { success = false, error = "invalid_request" } + end + local post = find_accessible_post(data.id, profile.id) + if not post then + return { success = false, error = "post_not_found" } + end + if data.active then + local result = Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_picstagram_reactions` (`post_id`, `profile_id`, `kind`) + VALUES (?, ?, ?) + ]], { data.id, profile.id, data.kind }) + if data.kind == "like" and affected_rows(result) > 0 then + create_activity(post.profile_id, profile.id, "like", data.id) + end + else + Bridge.Database.Query([[ + DELETE FROM `sky_phone_picstagram_reactions` + WHERE `post_id` = ? AND `profile_id` = ? AND `kind` = ? + ]], { data.id, profile.id, data.kind }) + end + return { success = true, data = { active = data.active == true } } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:follow", function(source, data) + if not SkyPhone.AllowOperation(source, "picstagram:follow", 40, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" + or not valid_id(data.profileId) + or data.profileId == profile.id + or type(data.active) ~= "boolean" + then + return { success = false, error = "invalid_profile" } + end + if are_profiles_blocked(profile.id, data.profileId) then + return { success = false, error = "blocked" } + end + local target = Bridge.Database.Query([[ + SELECT `id`, `private` FROM `sky_phone_picstagram_profiles` + WHERE `id` = ? AND `status` = 'active' LIMIT 1 + ]], { data.profileId })[1] + if not target then + return { success = false, error = "profile_not_found" } + end + if not data.active then + Bridge.Database.Query( + "DELETE FROM `sky_phone_picstagram_follows` WHERE `follower_id` = ? AND `following_id` = ?", + { profile.id, target.id } + ) + return { success = true, data = { status = false } } + end + local status = tonumber(target.private) == 1 and "pending" or "accepted" + local existing = Bridge.Database.Query([[ + SELECT `status` FROM `sky_phone_picstagram_follows` + WHERE `follower_id` = ? AND `following_id` = ? LIMIT 1 + ]], { profile.id, target.id })[1] + if not existing or existing.status ~= status then + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_follows` (`follower_id`, `following_id`, `status`) + VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `status` = VALUES(`status`), `updated_at` = CURRENT_TIMESTAMP + ]], { profile.id, target.id, status }) + create_activity(target.id, profile.id, status == "pending" and "follow_request" or "follow", nil) + end + return { success = true, data = { status = status } } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:respond-follow", function(source, data) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" or not valid_id(data.profileId) or type(data.accept) ~= "boolean" then + return { success = false, error = "invalid_profile" } + end + local request = Bridge.Database.Query([[ + SELECT `id` FROM `sky_phone_picstagram_follows` + WHERE `follower_id` = ? AND `following_id` = ? AND `status` = 'pending' LIMIT 1 + ]], { data.profileId, profile.id })[1] + if not request then + return { success = false, error = "request_not_found" } + end + if data.accept then + Bridge.Database.Query( + "UPDATE `sky_phone_picstagram_follows` SET `status` = 'accepted' WHERE `id` = ?", + { request.id } + ) + create_activity(data.profileId, profile.id, "request_accepted", nil) + else + Bridge.Database.Query("DELETE FROM `sky_phone_picstagram_follows` WHERE `id` = ?", { request.id }) + end + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:comments", function(source, data) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" or not valid_id(data.id) or not find_accessible_post(data.id, profile.id) then + return { success = false, error = "post_not_found" } + end + local rows = Bridge.Database.Query([[ + 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` + 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` + 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 }) + 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].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", function(source, data) + if not SkyPhone.AllowOperation(source, "picstagram:comment", Config.Picstagram.CommentsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" or not valid_id(data.id) then + return { success = false, error = "invalid_comment" } + end + local body = trim(data.body) + if not valid_text(body, 1, Config.Picstagram.CommentMaxLength) then + return { success = false, error = "invalid_comment" } + end + local post = find_accessible_post(data.id, profile.id) + if not post then + return { success = false, error = "post_not_found" } + end + 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 + return { success = false, error = "invalid_comment" } + end + end + local id = new_id() + if parent_id then + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_comments` (`id`, `post_id`, `profile_id`, `parent_id`, `body`) + VALUES (?, ?, ?, ?, ?) + ]], { id, data.id, profile.id, parent_id, body }) + else + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_comments` (`id`, `post_id`, `profile_id`, `body`) + 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) + end + return { success = true, data = { id = id } } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:remove-comment", function(source, data) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" or not valid_id(data.id) then + return { success = false, error = "invalid_comment" } + end + local rows = Bridge.Database.Query([[ + SELECT comment.`profile_id`, post.`profile_id` AS `post_owner_id` + FROM `sky_phone_picstagram_comments` comment + JOIN `sky_phone_picstagram_posts` post ON post.`id` = comment.`post_id` + WHERE comment.`id` = ? AND comment.`status` = 'visible' LIMIT 1 + ]], { data.id }) + local comment = rows[1] + if not comment or (comment.profile_id ~= profile.id and comment.post_owner_id ~= profile.id) then + return { success = false, error = "not_authorized" } + end + Bridge.Database.Query( + "UPDATE `sky_phone_picstagram_comments` SET `status` = 'removed' WHERE `id` = ?", + { data.id } + ) + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:publish-story", function(source, data) + if not SkyPhone.AllowOperation(source, "picstagram:story", Config.Picstagram.StoriesPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" 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") + then + return { success = false, error = "invalid_story" } + end + local id = new_id() + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_stories` + (`id`, `profile_id`, `media_id`, `body`, `expires_at`) + VALUES (?, ?, ?, ?, FROM_UNIXTIME(?)) + ]], { id, profile.id, media_id, body, os.time() + Config.Picstagram.StoryLifetimeSeconds }) + return { success = true, data = { id = id } } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:stories", function(source) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + local rows = Bridge.Database.Query([[ + SELECT story.`id`, story.`profile_id`, story.`body`, media.`url`, 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`, + EXISTS(SELECT 1 FROM `sky_phone_picstagram_story_views` view + WHERE view.`story_id` = story.`id` AND view.`profile_id` = ?) AS `seen`, + (story.`profile_id` = ?) AS `is_owner`, + (SELECT COUNT(*) FROM `sky_phone_picstagram_story_views` view + WHERE view.`story_id` = story.`id`) AS `view_count` + FROM `sky_phone_picstagram_stories` story + JOIN `sky_phone_picstagram_profiles` author ON author.`id` = story.`profile_id` + JOIN `sky_phone_media` media ON media.`id` = story.`media_id` + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = author.`avatar_media_id` + WHERE story.`status` = 'active' AND story.`expires_at` > CURRENT_TIMESTAMP + AND author.`status` = 'active' + AND (story.`profile_id` = ? OR EXISTS(SELECT 1 FROM `sky_phone_picstagram_follows` follow + WHERE follow.`follower_id` = ? AND follow.`following_id` = story.`profile_id` + AND follow.`status` = 'accepted')) + AND NOT EXISTS(SELECT 1 FROM `sky_phone_picstagram_blocks` block + WHERE (block.`blocker_id` = ? AND block.`blocked_id` = story.`profile_id`) + OR (block.`blocked_id` = ? AND block.`blocker_id` = story.`profile_id`)) + ORDER BY story.`profile_id` = ? DESC, story.`created_at` + ]], { profile.id, profile.id, profile.id, profile.id, profile.id, profile.id, profile.id }) + for index = 1, #rows do + local story = rows[index] + story.verified = tonumber(story.verified) == 1 + story.seen = tonumber(story.seen) == 1 + story.is_owner = tonumber(story.is_owner) == 1 + story.view_count = tonumber(story.view_count) or 0 + story.created_at = (tonumber(story.created_at_unix) or 0) * 1000 + story.expires_at = (tonumber(story.expires_at_unix) or 0) * 1000 + story.created_at_unix = nil + story.expires_at_unix = nil + end + return { success = true, data = rows } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:view-story", function(source, data) + if not SkyPhone.AllowOperation(source, "picstagram:story-view", 120, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" or not valid_id(data.id) then + return { success = false, error = "story_not_found" } + end + local story = Bridge.Database.Query([[ + SELECT story.`id`, story.`profile_id` FROM `sky_phone_picstagram_stories` story + JOIN `sky_phone_picstagram_profiles` author ON author.`id` = story.`profile_id` + WHERE story.`id` = ? AND story.`status` = 'active' AND story.`expires_at` > CURRENT_TIMESTAMP + AND author.`status` = 'active' + AND (author.`private` = 0 OR story.`profile_id` = ? OR EXISTS( + SELECT 1 FROM `sky_phone_picstagram_follows` follow + WHERE follow.`follower_id` = ? AND follow.`following_id` = story.`profile_id` + AND follow.`status` = 'accepted')) + LIMIT 1 + ]], { data.id, profile.id, profile.id })[1] + if not story or are_profiles_blocked(profile.id, story.profile_id) then + return { success = false, error = "story_not_found" } + end + if story.profile_id ~= profile.id then + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_picstagram_story_views` (`story_id`, `profile_id`) + VALUES (?, ?) + ]], { story.id, profile.id }) + end + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:story-viewers", function(source, data) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" or not valid_id(data.id) then + return { success = false, error = "story_not_found" } + end + if not Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_picstagram_stories` WHERE `id` = ? AND `profile_id` = ? LIMIT 1", + { data.id, profile.id } + )[1] then + return { success = false, error = "story_not_found" } + end + local rows = Bridge.Database.Query([[ + SELECT viewer.`id`, viewer.`handle`, viewer.`display_name`, viewer.`verified`, + avatar.`url` AS `avatar_url`, UNIX_TIMESTAMP(viewed.`created_at`) AS `created_at_unix` + FROM `sky_phone_picstagram_story_views` viewed + JOIN `sky_phone_picstagram_profiles` viewer ON viewer.`id` = viewed.`profile_id` + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = viewer.`avatar_media_id` + WHERE viewed.`story_id` = ? ORDER BY viewed.`created_at` DESC + ]], { data.id }) + for index = 1, #rows do + rows[index].verified = tonumber(rows[index].verified) == 1 + 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:remove-story", function(source, data) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" or not valid_id(data.id) then + return { success = false, error = "story_not_found" } + end + local result = Bridge.Database.Query([[ + UPDATE `sky_phone_picstagram_stories` SET `status` = 'removed' + WHERE `id` = ? AND `profile_id` = ? AND `status` = 'active' + ]], { data.id, profile.id }) + return affected_rows(result) > 0 + and { success = true } + or { success = false, error = "story_not_found" } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:activities", function(source) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + local rows = Bridge.Database.Query([[ + SELECT activity.`id`, activity.`kind`, activity.`post_id`, activity.`read_at`, + UNIX_TIMESTAMP(activity.`created_at`) AS `created_at_unix`, actor.`id` AS `profile_id`, + actor.`handle`, actor.`display_name`, actor.`verified`, avatar.`url` AS `avatar_url`, + cover.`url` AS `post_url` + FROM `sky_phone_picstagram_activities` activity + JOIN `sky_phone_picstagram_profiles` actor ON actor.`id` = activity.`actor_id` + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = actor.`avatar_media_id` + LEFT JOIN `sky_phone_picstagram_post_media` post_media + ON post_media.`post_id` = activity.`post_id` AND post_media.`position` = 1 + LEFT JOIN `sky_phone_media` cover ON cover.`id` = post_media.`media_id` + WHERE activity.`recipient_id` = ? + AND NOT EXISTS(SELECT 1 FROM `sky_phone_picstagram_blocks` block + WHERE (block.`blocker_id` = ? AND block.`blocked_id` = activity.`actor_id`) + OR (block.`blocked_id` = ? AND block.`blocker_id` = activity.`actor_id`)) + ORDER BY activity.`created_at` DESC LIMIT 100 + ]], { profile.id, profile.id, profile.id }) + for index = 1, #rows do + rows[index].verified = tonumber(rows[index].verified) == 1 + 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:mark-activities", function(source) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + Bridge.Database.Query([[ + UPDATE `sky_phone_picstagram_activities` SET `read_at` = CURRENT_TIMESTAMP + WHERE `recipient_id` = ? AND `read_at` IS NULL + ]], { profile.id }) + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:block", function(source, data) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" + or not valid_id(data.profileId) + or data.profileId == profile.id + or type(data.active) ~= "boolean" + then + return { success = false, error = "invalid_profile" } + end + if not Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_picstagram_profiles` WHERE `id` = ? AND `status` = 'active' LIMIT 1", + { data.profileId } + )[1] then + return { success = false, error = "profile_not_found" } + end + if data.active == false then + Bridge.Database.Query( + "DELETE FROM `sky_phone_picstagram_blocks` WHERE `blocker_id` = ? AND `blocked_id` = ?", + { profile.id, data.profileId } + ) + return { success = true } + end + if not Bridge.Database.Transaction({ + { + query = [[INSERT IGNORE INTO `sky_phone_picstagram_blocks` (`blocker_id`, `blocked_id`) + VALUES (?, ?)]], + params = { profile.id, data.profileId }, + }, + { + query = [[DELETE FROM `sky_phone_picstagram_follows` + WHERE (`follower_id` = ? AND `following_id` = ?) + OR (`follower_id` = ? AND `following_id` = ?)]], + params = { profile.id, data.profileId, data.profileId, profile.id }, + }, + }) then + return { success = false, error = "request_failed" } + end + return { success = true } +end) + +local function valid_report_target(target_type, target_id) + if target_type == "profile" then + return Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_picstagram_profiles` WHERE `id` = ? AND `status` = 'active' LIMIT 1", + { target_id } + )[1] ~= nil + end + local tables = { + post = "sky_phone_picstagram_posts", + story = "sky_phone_picstagram_stories", + comment = "sky_phone_picstagram_comments", + } + local table_name = tables[target_type] + if not table_name then + return false + end + return Bridge.Database.Query(("SELECT `id` FROM `%s` WHERE `id` = ? LIMIT 1"):format(table_name), { target_id })[1] ~= nil +end + +Bridge.Callbacks.Register("sky_phone:picstagram:report", function(source, data) + if not SkyPhone.AllowOperation(source, "picstagram:report", 10, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" or not valid_id(data.targetId) or not report_reasons[data.reason] then + return { success = false, error = "invalid_report" } + end + local details = trim(data.details) or "" + if not valid_text(details, 0, Config.Picstagram.ReportDetailsMaxLength) + or not valid_report_target(data.targetType, data.targetId) + then + return { success = false, error = "invalid_report" } + end + local result = Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_picstagram_reports` + (`id`, `reporter_id`, `target_type`, `target_id`, `reason`, `details`) + VALUES (?, ?, ?, ?, ?, ?) + ]], { new_id(), profile.id, data.targetType, data.targetId, data.reason, details }) + return affected_rows(result) > 0 + and { success = true } + or { success = false, error = "already_reported" } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:admin-reports", function(source) + if not is_admin(source) then + return { success = false, error = "not_authorized" } + end + local rows = Bridge.Database.Query([[ + SELECT report.`id`, report.`target_type`, report.`target_id`, report.`reason`, report.`details`, + UNIX_TIMESTAMP(report.`created_at`) AS `created_at_unix`, reporter.`handle` AS `reporter_handle`, + reporter.`display_name` AS `reporter_display_name` + FROM `sky_phone_picstagram_reports` report + JOIN `sky_phone_picstagram_profiles` reporter ON reporter.`id` = report.`reporter_id` + WHERE report.`status` = 'open' ORDER BY report.`created_at` LIMIT 100 + ]], {}) + for index = 1, #rows do + 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:admin-resolve-report", function(source, data) + if not is_admin(source) then + return { success = false, error = "not_authorized" } + end + local allowed_actions = { dismiss = true, hide = true, remove = true, restore = true } + if type(data) ~= "table" or not valid_id(data.id) or not allowed_actions[data.action] then + return { success = false, error = "invalid_request" } + end + local report = Bridge.Database.Query([[ + SELECT * FROM `sky_phone_picstagram_reports` WHERE `id` = ? AND `status` = 'open' LIMIT 1 + ]], { data.id })[1] + if not report then + return { success = false, error = "report_not_found" } + end + local statements = {} + if data.action ~= "dismiss" then + local targets = { + profile = { table = "sky_phone_picstagram_profiles", hidden = "hidden", removed = "removed", restored = "active" }, + post = { table = "sky_phone_picstagram_posts", hidden = "hidden", removed = "removed", restored = "published" }, + story = { table = "sky_phone_picstagram_stories", hidden = "removed", removed = "removed", restored = "active" }, + comment = { table = "sky_phone_picstagram_comments", hidden = "removed", removed = "removed", restored = "visible" }, + } + local target = targets[report.target_type] + if not target then + return { success = false, error = "invalid_request" } + end + local status = data.action == "hide" and target.hidden + or data.action == "remove" and target.removed + or target.restored + statements[#statements + 1] = { + query = ("UPDATE `%s` SET `status` = ? WHERE `id` = ?"):format(target.table), + params = { status, report.target_id }, + } + end + statements[#statements + 1] = { + query = [[UPDATE `sky_phone_picstagram_reports` + SET `status` = ?, `resolved_action` = ?, `resolved_at` = CURRENT_TIMESTAMP + WHERE `id` = ?]], + params = { data.action == "dismiss" and "dismissed" or "reviewed", data.action, report.id }, + } + statements[#statements + 1] = { + query = [[INSERT INTO `sky_phone_picstagram_moderation_audit` + (`id`, `report_id`, `moderator_identifier`, `action`, `target_type`, `target_id`) + VALUES (?, ?, ?, ?, ?, ?)]], + params = { + new_id(), + report.id, + Bridge.Framework.GetIdentifier(source) or ("source:%s"):format(source), + data.action, + report.target_type, + report.target_id, + }, + } + if not Bridge.Database.Transaction(statements) then + return { success = false, error = "request_failed" } + end + return { success = true } +end) + +RegisterCommand(Config.Picstagram.VerifyCommand, function(source, args) + local command_locale = (Locales[Config.Bridge.Locale] or Locales["en"]).PicstagramCommand + local function command_message(template, values) + return template:gsub("{(%w+)}", function(key) + return values[key] or "" + end) + end + local function send_command_feedback(message, notification_type) + if source == 0 then + print(message) + return + end + TriggerClientEvent("sky_phone:picstagram:command-feedback", source, { + message = message, + notificationType = notification_type, + }) + end + if source ~= 0 and not Bridge.Framework.HasAdminGroup(source, Config.Picstagram.AdminGroups) then + send_command_feedback(command_locale.noPermission, "error") + Bridge.Debug( + "warn", + "[sky_phone] Source %s attempted to use the Picstagram verification command without an admin group.", + tostring(source) + ) + return + end + local handle = normalize_handle(args[1]) + local state = args[2] == "on" and 1 or args[2] == "off" and 0 or nil + if not handle or state == nil then + send_command_feedback( + command_message(command_locale.usage, { command = Config.Picstagram.VerifyCommand }), + "error" + ) + return + end + local target = Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_picstagram_profiles` WHERE `handle` = ? LIMIT 1", + { handle } + )[1] + if not target then + send_command_feedback(command_message(command_locale.notFound, { handle = handle }), "error") + return + end + Bridge.Database.Query( + "UPDATE `sky_phone_picstagram_profiles` SET `verified` = ? WHERE `id` = ?", + { state, target.id } + ) + TriggerClientEvent("sky_phone:picstagram:verification-changed", -1, { + profileId = target.id, + verified = state == 1, + }) + send_command_feedback(command_message(command_locale.updated, { + handle = handle, + state = state == 1 and command_locale.verified or command_locale.unverified, + }), "success") +end, false) +end) diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql index 6a92bf3..7bafea1 100644 --- a/sky_phone/sql/install.sql +++ b/sky_phone/sql/install.sql @@ -423,3 +423,194 @@ CREATE TABLE IF NOT EXISTS `sky_phone_flare_messages` ( FOREIGN KEY (`match_id`) REFERENCES `sky_phone_flare_matches` (`id`) ON DELETE CASCADE, FOREIGN KEY (`sender_account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_picstagram_profiles` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `account_id` BIGINT UNSIGNED NOT NULL, + `handle` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, + `display_name` VARCHAR(40) NOT NULL, + `bio` VARCHAR(160) NOT NULL DEFAULT '', + `avatar_media_id` BIGINT UNSIGNED NULL, + `private` TINYINT(1) NOT NULL DEFAULT 0, + `verified` TINYINT(1) NOT NULL DEFAULT 0, + `status` ENUM('active','hidden','removed') NOT NULL DEFAULT 'active', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_picstagram_account` (`account_id`), + UNIQUE KEY `uniq_sky_phone_picstagram_handle` (`handle`), + FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`avatar_media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_picstagram_credentials` ( + `profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `password_hash` BINARY(32) NOT NULL, + `password_salt` CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`profile_id`), + 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_sessions` ( + `device_imei` CHAR(15) 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, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`device_imei`), + KEY `idx_sky_phone_picstagram_sessions_profile` (`profile_id`,`updated_at`), + FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) 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_posts` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `caption` VARCHAR(800) NOT NULL DEFAULT '', + `location` VARCHAR(80) NOT NULL DEFAULT '', + `comments_enabled` TINYINT(1) NOT NULL DEFAULT 1, + `status` ENUM('published','archived','hidden','removed') NOT NULL DEFAULT 'published', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sky_phone_picstagram_feed` (`status`,`created_at`,`id`), + KEY `idx_sky_phone_picstagram_profile_posts` (`profile_id`,`status`,`created_at`), + 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_post_media` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `post_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `media_id` BIGINT UNSIGNED NOT NULL, + `position` TINYINT UNSIGNED NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_picstagram_post_position` (`post_id`,`position`), + UNIQUE KEY `uniq_sky_phone_picstagram_post_media` (`post_id`,`media_id`), + FOREIGN KEY (`post_id`) REFERENCES `sky_phone_picstagram_posts` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_picstagram_reactions` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `post_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `kind` ENUM('like','save') NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_picstagram_reaction` (`post_id`,`profile_id`,`kind`), + KEY `idx_sky_phone_picstagram_saved` (`profile_id`,`kind`,`created_at`), + FOREIGN KEY (`post_id`) REFERENCES `sky_phone_picstagram_posts` (`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_follows` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `follower_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `following_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `status` ENUM('pending','accepted') NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_picstagram_follow` (`follower_id`,`following_id`), + KEY `idx_sky_phone_picstagram_following` (`following_id`,`status`,`created_at`), + FOREIGN KEY (`follower_id`) REFERENCES `sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`following_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_comments` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `post_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `parent_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + `body` VARCHAR(300) NOT NULL, + `status` ENUM('visible','removed') NOT NULL DEFAULT 'visible', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sky_phone_picstagram_comments` (`post_id`,`status`,`created_at`), + FOREIGN KEY (`post_id`) REFERENCES `sky_phone_picstagram_posts` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE, + 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_stories` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `media_id` BIGINT UNSIGNED NOT NULL, + `body` VARCHAR(160) NOT NULL DEFAULT '', + `status` ENUM('active','removed') NOT NULL DEFAULT 'active', + `expires_at` DATETIME NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sky_phone_picstagram_stories` (`profile_id`,`status`,`expires_at`), + KEY `idx_sky_phone_picstagram_story_expiry` (`status`,`expires_at`), + FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_picstagram_story_views` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `story_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_story_view` (`story_id`,`profile_id`), + FOREIGN KEY (`story_id`) REFERENCES `sky_phone_picstagram_stories` (`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_activities` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `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, + `read_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sky_phone_picstagram_activity` (`recipient_id`,`read_at`,`created_at`), + FOREIGN KEY (`recipient_id`) REFERENCES `sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`actor_id`) REFERENCES `sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`post_id`) REFERENCES `sky_phone_picstagram_posts` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_picstagram_reports` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `reporter_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `target_type` ENUM('profile','post','story','comment') NOT NULL, + `target_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `reason` ENUM('spam','harassment','dangerous','illegal','other') NOT NULL, + `details` VARCHAR(500) NOT NULL DEFAULT '', + `status` ENUM('open','reviewed','dismissed') NOT NULL DEFAULT 'open', + `resolved_action` VARCHAR(16) CHARACTER SET ascii COLLATE ascii_general_ci NULL, + `resolved_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_picstagram_report` (`reporter_id`,`target_type`,`target_id`), + KEY `idx_sky_phone_picstagram_reports` (`status`,`created_at`), + FOREIGN KEY (`reporter_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_blocks` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `blocker_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `blocked_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_block` (`blocker_id`,`blocked_id`), + FOREIGN KEY (`blocker_id`) REFERENCES `sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`blocked_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_moderation_audit` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `report_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + `moderator_identifier` VARCHAR(80) NOT NULL, + `action` VARCHAR(16) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, + `target_type` VARCHAR(16) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, + `target_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sky_phone_picstagram_audit_target` (`target_type`,`target_id`,`created_at`), + FOREIGN KEY (`report_id`) REFERENCES `sky_phone_picstagram_reports` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;