diff --git a/frontend/src/stores/fliptok.test.ts b/frontend/src/stores/fliptok.test.ts index b1f0ad1..70bee90 100644 --- a/frontend/src/stores/fliptok.test.ts +++ b/frontend/src/stores/fliptok.test.ts @@ -183,4 +183,12 @@ describe('FlipTok verification updates', () => { expect(store.feed).toEqual([]) expect(store.activities).toEqual([]) }) + + it('loads an exact shared video by id', async () => { + vi.mocked(nuiCall).mockResolvedValue({ success: true, data: { ...video } }) + const store = useFlipTokStore() + + expect(await store.loadVideo(video.id)).toEqual(video) + expect(nuiCall).toHaveBeenCalledWith('fliptok:video', { id: video.id }) + }) }) diff --git a/frontend/src/stores/fliptok.ts b/frontend/src/stores/fliptok.ts index 53f95c3..2bc5960 100644 --- a/frontend/src/stores/fliptok.ts +++ b/frontend/src/stores/fliptok.ts @@ -114,6 +114,10 @@ export const useFlipTokStore = defineStore('fliptok', { this.feed = response.data.items return true }, + async loadVideo(id: string): Promise { + const response = await nuiCall('fliptok:video', { id }) + return response.success && response.data ? response.data : null + }, async discover(search: string): Promise { const response = await nuiCall('fliptok:discover', { search, diff --git a/frontend/src/stores/picstagram.test.ts b/frontend/src/stores/picstagram.test.ts index d23e934..e9567ac 100644 --- a/frontend/src/stores/picstagram.test.ts +++ b/frontend/src/stores/picstagram.test.ts @@ -213,4 +213,12 @@ describe('Picstagram store', () => { expect(store.feed).toEqual([]) expect(store.stories).toEqual([]) }) + + it('loads an exact shared post by id', async () => { + vi.mocked(nuiCall).mockResolvedValue({ success: true, data: { ...post } }) + const store = usePicstagramStore() + + expect(await store.loadPost(post.id)).toEqual(post) + expect(nuiCall).toHaveBeenCalledWith('picstagram:post', { id: post.id }) + }) }) diff --git a/frontend/src/stores/picstagram.ts b/frontend/src/stores/picstagram.ts index eee6c31..2647ee7 100644 --- a/frontend/src/stores/picstagram.ts +++ b/frontend/src/stores/picstagram.ts @@ -127,6 +127,10 @@ export const usePicstagramStore = defineStore('picstagram', { this.feedCursor = response.data.nextCursor return true }, + async loadPost(id: string): Promise { + const response = await nuiCall('picstagram:post', { id }) + return response.success && response.data ? response.data : null + }, async loadExplore(append = false): Promise { const response = await nuiCall('picstagram:explore', { cursor: append ? this.exploreCursor : undefined, diff --git a/frontend/src/utils/easyshare.test.ts b/frontend/src/utils/easyshare.test.ts index abe890a..f3fc67c 100644 --- a/frontend/src/utils/easyshare.test.ts +++ b/frontend/src/utils/easyshare.test.ts @@ -49,4 +49,26 @@ describe('EasyShare deep links', () => { ).query.listingId, ).toBe('listing-1') }) + + it.each([ + ['picstagram', 'post', 'pic-post-1', '/apps/picstagram'], + ['picstagram', 'profile', 'pic-profile-1', '/apps/picstagram'], + ['feather', 'post', 'feather-post-1', '/apps/feather'], + ['fliptok', 'post', 'fliptok-1', '/apps/fliptok'], + ['local-pages', 'post', 'pages-post-1', '/apps/local-pages'], + ])('preserves the exact %s %s target', (appId, kind, id, path) => { + expect( + easyShareRoute( + payload({ + appId: appId as EasySharePayload['appId'], + id, + kind: kind as EasySharePayload['kind'], + link: `skyphone://${appId}/${kind}/${id}`, + }), + ), + ).toMatchObject({ + path, + query: { easyShareId: id, easyShareKind: kind }, + }) + }) }) diff --git a/frontend/src/views/apps/FeatherApp.vue b/frontend/src/views/apps/FeatherApp.vue index 497b988..96e9ab7 100644 --- a/frontend/src/views/apps/FeatherApp.vue +++ b/frontend/src/views/apps/FeatherApp.vue @@ -709,6 +709,17 @@ onMounted(async () => { if (route.query.compose === '1') screen.value = 'composer' await feather.bootstrap() if (feather.onboarded) { + const easyShareId = String(route.query.easyShareId ?? '') + if (easyShareId && route.query.easyShareKind === 'profile') { + const profileId = Number(easyShareId) + if (Number.isInteger(profileId) && profileId > 0) + await openProfile(profileId) + } else if (easyShareId && route.query.easyShareKind === 'post') { + if (await feather.loadThread(easyShareId)) { + threadReplyTarget.value = feather.thread?.post ?? null + screen.value = 'thread' + } + } await feather.loadActivities() if (tab.value === 'profile' && feather.profile) await feather.loadProfile(feather.profile.id) diff --git a/frontend/src/views/apps/FlipTokApp.vue b/frontend/src/views/apps/FlipTokApp.vue index a6ad07e..a79fa4d 100644 --- a/frontend/src/views/apps/FlipTokApp.vue +++ b/frontend/src/views/apps/FlipTokApp.vue @@ -50,7 +50,7 @@ import { kToggle, } from 'konsta/vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' -import { useRouter } from 'vue-router' +import { useRoute, useRouter } from 'vue-router' import flipTokIcon from '@/assets/img/app-icons/fliptok.webp' import { useFlipTokStore } from '@/stores/fliptok' @@ -71,6 +71,7 @@ type AuthMode = 'login' | 'register' const phone = usePhoneStore() const store = useFlipTokStore() const messageMedia = useMessageMediaStore() +const route = useRoute() const router = useRouter() const authMode = ref('login') const authHandle = ref('') @@ -776,6 +777,18 @@ onMounted(async () => { composeOpen.value = true } await store.bootstrap() + const easyShareId = String(route.query.easyShareId ?? '') + if (easyShareId && route.query.easyShareKind === 'profile') { + const profileId = Number(easyShareId) + if (Number.isInteger(profileId) && profileId > 0) + await openProfile(profileId) + } else if (easyShareId && route.query.easyShareKind === 'post') { + const video = await store.loadVideo(easyShareId) + if (video) { + store.feed = [video] + tab.value = 'feed' + } + } await nextTick() observeVideos() }) diff --git a/frontend/src/views/apps/LocalPagesApp.vue b/frontend/src/views/apps/LocalPagesApp.vue index 3e36420..095ff83 100644 --- a/frontend/src/views/apps/LocalPagesApp.vue +++ b/frontend/src/views/apps/LocalPagesApp.vue @@ -279,7 +279,7 @@ function sharePost(): void { }) } -onMounted(() => { +onMounted(async () => { const selection = messageMedia.consumeMany('local-pages:compose') if (selection) { if (selection.context) { @@ -294,7 +294,16 @@ onMounted(() => { } } if (route.query.compose === '1') screen.value = 'compose' - void loadFeed() + await loadFeed() + const easyShareId = String(route.query.easyShareId ?? '') + if (easyShareId && route.query.easyShareKind === 'post') { + const response = await pages.get(easyShareId) + if (response.success && response.data) { + selected.value = response.data + galleryIndex.value = 0 + screen.value = 'detail' + } + } }) diff --git a/frontend/src/views/apps/PicstagramApp.vue b/frontend/src/views/apps/PicstagramApp.vue index 17c20bf..dc7a6d6 100644 --- a/frontend/src/views/apps/PicstagramApp.vue +++ b/frontend/src/views/apps/PicstagramApp.vue @@ -55,7 +55,7 @@ import { kToggle, } from 'konsta/vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' -import { useRouter } from 'vue-router' +import { useRoute, useRouter } from 'vue-router' import picstagramIcon from '@/assets/img/app-icons/picstagram.webp' import { useMessageMediaStore } from '@/stores/messageMedia' @@ -80,6 +80,7 @@ type ProfileSection = 'photos' | 'saved' const phone = usePhoneStore() const store = usePicstagramStore() const mediaPicker = useMessageMediaStore() +const route = useRoute() const router = useRouter() const tab = ref('home') @@ -629,6 +630,16 @@ watch(profileSection, (section) => { onMounted(async () => { await store.bootstrap() + const easyShareId = String(route.query.easyShareId ?? '') + if (easyShareId && route.query.easyShareKind === 'profile') { + await openProfile(easyShareId) + } else if (easyShareId && route.query.easyShareKind === 'post') { + const post = await store.loadPost(easyShareId) + if (post) { + tab.value = 'home' + selectedPost.value = post + } + } const composeSelection = mediaPicker.consumeMany<{ caption?: string commentsEnabled?: boolean diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index 86bad8f..65e699d 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -5387,6 +5387,15 @@ app.post('/api/:endpoint', (request, response) => { }) return } + if (endpoint === 'picstagram:post') { + const post = picstagramPosts.find((item) => item.id === request.body.id) + response.json( + post + ? { success: true, data: post } + : { success: false, error: 'post_not_found' }, + ) + return + } if (endpoint === 'picstagram:explore') { response.json({ success: true, @@ -5768,6 +5777,15 @@ app.post('/api/:endpoint', (request, response) => { response.json({ success: true }) return } + if (endpoint === 'fliptok:video') { + const video = flipTokVideos.find((item) => item.id === request.body.id) + response.json( + video + ? { success: true, data: video } + : { success: false, error: 'video_not_found' }, + ) + return + } if (endpoint === 'fliptok:follow') { flipTokVideos .filter((video) => video.profile_id === request.body.profileId) diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index 5799100..047e897 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -69,6 +69,7 @@ local server_callbacks = { "fliptok:logout", "fliptok:bootstrap", "fliptok:feed", + "fliptok:video", "fliptok:discover", "fliptok:publish", "fliptok:react", @@ -91,6 +92,7 @@ local server_callbacks = { "picstagram:logout", "picstagram:bootstrap", "picstagram:feed", + "picstagram:post", "picstagram:explore", "picstagram:search", "picstagram:saved", diff --git a/sky_phone/source/server/fliptok.lua b/sky_phone/source/server/fliptok.lua index 8f9f7da..03a20ad 100644 --- a/sky_phone/source/server/fliptok.lua +++ b/sky_phone/source/server/fliptok.lua @@ -303,6 +303,32 @@ end) Bridge.Callbacks.Register("sky_phone:fliptok:feed", feed) +Bridge.Callbacks.Register("sky_phone:fliptok:video", function(source, data) + local profile, error_response = require_profile(source) + if not profile then return error_response end + if not SkyPhone.AllowOperation(source, "fliptok:video", 60, 60) then + return { success = false, error = "rate_limited" } + end + local video_id = type(data) == "table" and data.id or nil + if type(video_id) ~= "string" or #video_id > 64 then + return { success = false, error = "video_not_found" } + end + local rows = list_videos( + profile.id, + [[v.`id` = ? AND (v.`visibility` = 'public' OR v.`profile_id` = ? OR + (v.`visibility` = 'followers' AND EXISTS( + SELECT 1 FROM `sky_phone_fliptok_follows` follow + WHERE follow.`follower_id` = ? AND follow.`following_id` = v.`profile_id`)))]], + { video_id, profile.id, profile.id, profile.id, profile.id }, + 1, + 0, + "v.`created_at` DESC" + ) + return rows[1] + and { success = true, data = rows[1] } + or { success = false, error = "video_not_found" } +end) + Bridge.Callbacks.Register("sky_phone:fliptok:discover", function(source, data) local profile, error_response = require_profile(source) if not profile then return error_response end diff --git a/sky_phone/source/server/picstagram.lua b/sky_phone/source/server/picstagram.lua index 683dc34..5d0f790 100644 --- a/sky_phone/source/server/picstagram.lua +++ b/sky_phone/source/server/picstagram.lua @@ -466,6 +466,35 @@ Bridge.Callbacks.Register("sky_phone:picstagram:feed", function(source, data) return result and { success = true, data = result } or { success = false, error = list_error } end) +Bridge.Callbacks.Register("sky_phone:picstagram:post", function(source, data) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if not SkyPhone.AllowOperation(source, "picstagram:post", 60, 60) then + return { success = false, error = "rate_limited" } + end + local post_id = type(data) == "table" and data.id or nil + if not valid_id(post_id) then + return { success = false, error = "post_not_found" } + end + local result, list_error = list_posts( + profile.id, + [[post.`id` = ? 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'))]], + { post_id, profile.id, profile.id }, + nil + ) + if not result then + return { success = false, error = list_error } + end + return result.items[1] + and { success = true, data = result.items[1] } + or { success = false, error = "post_not_found" } +end) + Bridge.Callbacks.Register("sky_phone:picstagram:explore", function(source, data) local profile, error_response = profile_for_session(source) if not profile then