diff --git a/frontend/src/stores/fliptok.test.ts b/frontend/src/stores/fliptok.test.ts index 234b448..9d41154 100644 --- a/frontend/src/stores/fliptok.test.ts +++ b/frontend/src/stores/fliptok.test.ts @@ -234,6 +234,24 @@ describe('FlipTok verification updates', () => { }) }) + it('marks every account in the own following list as already followed', async () => { + const followedProfile = { + ...profile, + id: 8, + is_following: false, + is_owner: false, + } + vi.mocked(nuiCall).mockResolvedValue({ + success: true, + data: [followedProfile], + }) + const store = useFlipTokStore() + store.profile = { ...profile } + + expect(await store.loadConnections(profile.id, 'following')).toBe(true) + expect(store.connections[0]?.is_following).toBe(true) + }) + it('does not show a follow state when the server rejects it', async () => { vi.mocked(nuiCall).mockResolvedValue({ success: false }) const store = useFlipTokStore() diff --git a/frontend/src/stores/fliptok.ts b/frontend/src/stores/fliptok.ts index 7d910d0..507d5c7 100644 --- a/frontend/src/stores/fliptok.ts +++ b/frontend/src/stores/fliptok.ts @@ -238,6 +238,11 @@ export const useFlipTokStore = defineStore('fliptok', { profileId, }) this.connections = response.success && response.data ? response.data : [] + if (mode === 'following' && profileId === this.profile?.id) { + this.connections.forEach((profile) => { + if (!profile.is_owner) profile.is_following = true + }) + } return response.success }, async loadActivities(): Promise { diff --git a/frontend/src/views/apps/FlipTokApp.contract.test.ts b/frontend/src/views/apps/FlipTokApp.contract.test.ts index a99ec8a..2809b77 100644 --- a/frontend/src/views/apps/FlipTokApp.contract.test.ts +++ b/frontend/src/views/apps/FlipTokApp.contract.test.ts @@ -98,11 +98,15 @@ describe('FlipTokApp Sky UI contract', () => { expect(source).toContain("route.query.compose === '1'") expect(source).toContain('mediaIds,') expect(source).toContain('class="photo-slideshow"') - expect(source).toContain('@pointerdown="beginPhotoSlideDrag(video.id, $event)"') + expect(source).toContain( + '@pointerdown="beginPhotoSlideDrag(video.id, $event)"', + ) expect(source).toContain('@pointermove="updatePhotoSlideDrag"') expect(source).toContain('@pointerup="endPhotoSlideDrag(video, $event)"') expect(source).not.toContain('photo-slideshow__arrow') - expect(source).toContain('window.requestAnimationFrame(() => renderPhotoSlideDrag(drag))') + expect(source).toContain( + 'window.requestAnimationFrame(() => renderPhotoSlideDrag(drag))', + ) expect(source).toContain('function settlePhotoSlide(') expect(source).toContain('class="photo-slideshow__track"') expect(source).toContain('const animation = track.animate(') @@ -129,7 +133,10 @@ describe('FlipTokApp Sky UI contract', () => { it('shows a centered transient follow control', () => { expect(source).toContain('followFeedbackIds') expect(source).toContain('follow-dot--confirmed') - expect(source).toContain('@click="followFromFeed(video)"') + expect(source).toContain('@click.stop="followFromFeed(video)"') + expect(source).toContain( + 'video.is_following || followPendingIds.has(video.id)', + ) expect(source).toMatch( /\.video-actions \.follow-dot\s*\{[^}]*min-width: 20px !important;[^}]*min-height: 20px !important;[^}]*place-items: center;/s, ) @@ -150,10 +157,21 @@ describe('FlipTokApp Sky UI contract', () => { expect(source).toMatch( /\.profile-navbar-actions :deep\(\.sky-link\)\s*\{[^}]*width: 38px;[^}]*border:/s, ) - expect(source).toContain(':disabled="profile.is_following"') + expect(source).toContain(':disabled="connectionIsFollowing(profile)"') + expect(source).toContain("connectionsMode.value === 'following'") expect(source).toContain('@click="followConnection(profile)"') }) + it('labels authentication modes and centers connection avatar fallbacks', () => { + expect(source).toContain( + ":title=\"t(authMode === 'login' ? 'login' : 'register')\"", + ) + expect(source).toContain('class="connection-avatar__fallback"') + expect(source).toMatch( + /\.connections-list \.connection-avatar__fallback\s*\{[^}]*display:\s*grid;[^}]*place-items:\s*center;/s, + ) + }) + it('supports validated custom audio links in the composer', () => { expect(source).toContain('function validCustomMusicUrl') expect(source).toContain('customMusicDraftUrl') diff --git a/frontend/src/views/apps/FlipTokApp.vue b/frontend/src/views/apps/FlipTokApp.vue index edaa799..90539ea 100644 --- a/frontend/src/views/apps/FlipTokApp.vue +++ b/frontend/src/views/apps/FlipTokApp.vue @@ -361,9 +361,7 @@ function beginPhotoSlideDrag(videoId: string, event: PointerEvent): void { element.setPointerCapture(event.pointerId) } -function renderPhotoSlideDrag( - drag: NonNullable, -): void { +function renderPhotoSlideDrag(drag: NonNullable): void { drag.frame = null if (photoSlideDrag !== drag) return const translateX = drag.startTranslateX + (drag.currentX - drag.startX) @@ -409,7 +407,8 @@ function endPhotoSlideDrag(video: FlipTokVideo, event: PointerEvent): void { shouldAdvance ? drag.startIndex + direction : drag.startIndex, ), ) - if (element.hasPointerCapture(pointerId)) element.releasePointerCapture(pointerId) + if (element.hasPointerCapture(pointerId)) + element.releasePointerCapture(pointerId) element.classList.remove('photo-slideshow--dragging') photoSlideDrag = null settlePhotoSlide(video.id, element, nextIndex) @@ -437,7 +436,10 @@ function settlePhotoSlide( element.classList.add('photo-slideshow--settling') const animation = track.animate( [ - { transform: startTransform === 'none' ? track.style.transform : startTransform }, + { + transform: + startTransform === 'none' ? track.style.transform : startTransform, + }, { transform: animationTarget }, ], { @@ -1401,10 +1403,18 @@ async function openConnectionProfile(profile: FlipTokProfile): Promise { } async function followConnection(profile: FlipTokProfile): Promise { - if (profile.is_owner || profile.is_following) return + if (profile.is_owner || connectionIsFollowing(profile)) return if (!(await store.followProfile(profile))) notify(t('errors.default')) } +function connectionIsFollowing(profile: FlipTokProfile): boolean { + return ( + profile.is_following || + (connectionsMode.value === 'following' && + currentProfile.value?.is_owner === true) + ) +} + function openActions(video: FlipTokVideo): void { selectedVideo.value = video actionsOpen.value = true @@ -1767,7 +1777,7 @@ onBeforeUnmount(() => { > @@ -2037,13 +2047,15 @@ onBeforeUnmount(() => { !video.is_owner && (!video.is_following || followFeedbackIds.has(video.id)) " + type="button" class="follow-dot" :class="{ 'follow-dot--confirmed': video.is_following, 'follow-dot--pending': followPendingIds.has(video.id), }" - :disabled="followPendingIds.has(video.id)" - @click="followFromFeed(video)" + :aria-label="video.is_following ? t('unfollow') : t('follow')" + :disabled="video.is_following || followPendingIds.has(video.id)" + @click.stop="followFromFeed(video)" > @@ -3277,9 +3289,9 @@ onBeforeUnmount(() => { :src="profile.avatar_url" alt="" /> - + + {{ initials(profile.display_name) }} + @@ -3288,11 +3300,11 @@ onBeforeUnmount(() => { v-if="!profile.is_owner" small rounded - :tonal="profile.is_following" - :disabled="profile.is_following" + :tonal="connectionIsFollowing(profile)" + :disabled="connectionIsFollowing(profile)" @click="followConnection(profile)" >{{ - profile.is_following ? t('unfollow') : t('follow') + connectionIsFollowing(profile) ? t('unfollow') : t('follow') }} @@ -5905,12 +5917,18 @@ onBeforeUnmount(() => { border: 2px solid #111; border-radius: 50% !important; background: var(--sky-app-accent) !important; + line-height: 0; } .video-actions .follow-dot svg { width: 11px !important; height: 11px !important; margin: 0; + stroke-width: 3; +} + +.video-actions .follow-dot:disabled { + opacity: 1; } /* No unused action row is reserved above Discover. */ @@ -6046,10 +6064,21 @@ onBeforeUnmount(() => { } .connections-list .connection-avatar { + display: grid; + place-items: center; line-height: 1; text-align: center; } +.connections-list .connection-avatar__fallback { + width: 100%; + height: 100%; + display: grid; + place-items: center; + line-height: 1; + transform: translateY(-0.5px); +} + .connections-list :deep(.sky-button:disabled) { opacity: 0.72; }