mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 09:13:24 +00:00
ADD - implement Picstagram app
This commit is contained in:
@@ -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<AppMessage>): 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
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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<boolean> {
|
||||
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<NuiResponse> {
|
||||
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<NuiResponse> {
|
||||
const response = await nuiCall('picstagram:register', {
|
||||
displayName,
|
||||
handle,
|
||||
password,
|
||||
})
|
||||
if (response.success) await this.bootstrap()
|
||||
return response
|
||||
},
|
||||
async logout(): Promise<NuiResponse> {
|
||||
const response = await nuiCall('picstagram:logout')
|
||||
if (response.success) this.$reset()
|
||||
return response
|
||||
},
|
||||
async loadFeed(append = false): Promise<boolean> {
|
||||
const response = await nuiCall<PicstagramPage>('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<boolean> {
|
||||
const response = await nuiCall<PicstagramPage>('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<boolean> {
|
||||
if (!search.trim()) {
|
||||
this.searchPosts = []
|
||||
this.searchProfiles = []
|
||||
return true
|
||||
}
|
||||
const response = await nuiCall<PicstagramSearchResult>(
|
||||
'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<boolean> {
|
||||
const response = await nuiCall<PicstagramPage>('picstagram:saved')
|
||||
if (!response.success || !response.data) return false
|
||||
this.saved = response.data.items
|
||||
return true
|
||||
},
|
||||
async loadProfile(query: {
|
||||
handle?: string
|
||||
profileId?: string
|
||||
}): Promise<boolean> {
|
||||
const response = await nuiCall<PicstagramProfilePage>(
|
||||
'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<NuiResponse<PicstagramProfile>> {
|
||||
const response = await nuiCall<PicstagramProfile>(
|
||||
'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<NuiResponse<{ id: string }>> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
const response = await nuiCall<PicstagramComment[]>(
|
||||
'picstagram:comments',
|
||||
{ id },
|
||||
)
|
||||
this.comments = response.success && response.data ? response.data : []
|
||||
return response.success
|
||||
},
|
||||
async comment(
|
||||
id: string,
|
||||
body: string,
|
||||
parentId?: string,
|
||||
): Promise<NuiResponse> {
|
||||
return nuiCall('picstagram:comment', { body, id, parentId })
|
||||
},
|
||||
async removeComment(id: string): Promise<boolean> {
|
||||
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<boolean> {
|
||||
const response = await nuiCall<PicstagramStory[]>('picstagram:stories')
|
||||
this.stories = response.success && response.data ? response.data : []
|
||||
return response.success
|
||||
},
|
||||
async publishStory(
|
||||
mediaId: number,
|
||||
body: string,
|
||||
): Promise<NuiResponse<{ id: string }>> {
|
||||
const response = await nuiCall<{ id: string }>(
|
||||
'picstagram:publish-story',
|
||||
{ body, mediaId },
|
||||
)
|
||||
if (response.success) await this.loadStories()
|
||||
return response
|
||||
},
|
||||
async viewStory(story: PicstagramStory): Promise<void> {
|
||||
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<boolean> {
|
||||
const response = await nuiCall<PicstagramStoryViewer[]>(
|
||||
'picstagram:story-viewers',
|
||||
{ id },
|
||||
)
|
||||
this.storyViewers = response.success && response.data ? response.data : []
|
||||
return response.success
|
||||
},
|
||||
async removeStory(id: string): Promise<boolean> {
|
||||
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<boolean> {
|
||||
const response = await nuiCall<PicstagramActivity[]>(
|
||||
'picstagram:activities',
|
||||
)
|
||||
this.activities = response.success && response.data ? response.data : []
|
||||
return response.success
|
||||
},
|
||||
async markActivities(): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<NuiResponse> {
|
||||
return nuiCall('picstagram:report', {
|
||||
details,
|
||||
reason,
|
||||
targetId,
|
||||
targetType,
|
||||
})
|
||||
},
|
||||
async loadReports(): Promise<boolean> {
|
||||
const response = await nuiCall<PicstagramReport[]>(
|
||||
'picstagram:admin-reports',
|
||||
)
|
||||
this.reports = response.success && response.data ? response.data : []
|
||||
return response.success
|
||||
},
|
||||
async resolveReport(
|
||||
id: string,
|
||||
action: 'dismiss' | 'hide' | 'remove' | 'restore',
|
||||
): Promise<boolean> {
|
||||
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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -29,6 +29,7 @@ export type PhoneAppId =
|
||||
| 'local-pages'
|
||||
| 'flare'
|
||||
| 'fliptok'
|
||||
| 'picstagram'
|
||||
|
||||
export type LaunchablePhoneAppId = PhoneAppId
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user