Initial Commit
This commit is contained in:
@@ -5,7 +5,7 @@ namespace App\Console\Commands;
|
|||||||
use Illuminate\Console\Attributes\Description;
|
use Illuminate\Console\Attributes\Description;
|
||||||
use Illuminate\Console\Attributes\Signature;
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
|
||||||
#[Signature('app:debug-playwright')]
|
#[Signature('direct:debug-playwright')]
|
||||||
#[Description('Command description')]
|
#[Description('Command description')]
|
||||||
class DebugPlaywright extends RunsPlaywrightScript
|
class DebugPlaywright extends RunsPlaywrightScript
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ class ScrapeDirectMessages extends RunsPlaywrightScript
|
|||||||
'shared_preview_image' => $message['share']['preview_image'] ?? null,
|
'shared_preview_image' => $message['share']['preview_image'] ?? null,
|
||||||
'shared_title' => $message['share']['title'] ?? null,
|
'shared_title' => $message['share']['title'] ?? null,
|
||||||
'shared_caption' => $message['share']['caption'] ?? null,
|
'shared_caption' => $message['share']['caption'] ?? null,
|
||||||
|
'attachments' => $message['attachments'] ?? null,
|
||||||
'reactions' => $message['reactions'],
|
'reactions' => $message['reactions'],
|
||||||
'replied_to_id' => $message['replied_to_id'],
|
'replied_to_id' => $message['replied_to_id'],
|
||||||
'replied_to_text' => $message['replied_to_text'],
|
'replied_to_text' => $message['replied_to_text'],
|
||||||
|
|||||||
@@ -69,4 +69,33 @@ class StoryController extends Controller
|
|||||||
Story::where('id', $id)->update(['viewed_at' => now()]);
|
Story::where('id', $id)->update(['viewed_at' => now()]);
|
||||||
return response()->json(['ok' => true]);
|
return response()->json(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single story item by its own id, regardless of whether it's
|
||||||
|
* still active — used for opening a story someone shared in a DM long
|
||||||
|
* after it expired from the live tray.
|
||||||
|
*/
|
||||||
|
public function showItem(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$item = Story::find($id);
|
||||||
|
|
||||||
|
if (! $item) {
|
||||||
|
return response()->json(['message' => 'Story not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'username' => $item->username,
|
||||||
|
'profile_pic_url' => $item->profile_pic_url,
|
||||||
|
'start_index' => 0,
|
||||||
|
'items' => [[
|
||||||
|
'id' => $item->id,
|
||||||
|
'code' => $item->code,
|
||||||
|
'is_video' => $item->is_video,
|
||||||
|
'image_url' => $item->image_url,
|
||||||
|
'video_url' => $item->video_url,
|
||||||
|
'accessibility_caption' => $item->accessibility_caption,
|
||||||
|
'taken_at' => $item->taken_at,
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,25 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Models\DirectMessage;
|
use App\Models\DirectMessage;
|
||||||
use App\Models\DirectThread;
|
use App\Models\DirectThread;
|
||||||
|
use App\Models\Story;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
class ThreadController extends Controller
|
class ThreadController extends Controller
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Instagram's own story permalinks embed both the poster's username and
|
||||||
|
* the story item's numeric pk: instagram.com/stories/{username}/{pk}.
|
||||||
|
* That pk is the same id our story scraper stores.
|
||||||
|
*/
|
||||||
|
private function parseStorySharePk(?string $url): ?array
|
||||||
|
{
|
||||||
|
if (! $url || ! preg_match('#/stories/([^/?]+)/(\d+)#', $url, $matches)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['username' => $matches[1], 'item_id' => $matches[2]];
|
||||||
|
}
|
||||||
|
|
||||||
public function index(): JsonResponse
|
public function index(): JsonResponse
|
||||||
{
|
{
|
||||||
$threads = DirectThread::query()
|
$threads = DirectThread::query()
|
||||||
@@ -40,10 +55,23 @@ class ThreadController extends Controller
|
|||||||
{
|
{
|
||||||
$thread = DirectThread::findOrFail($threadId);
|
$thread = DirectThread::findOrFail($threadId);
|
||||||
|
|
||||||
$messages = $thread->messages()
|
$messages = $thread->messages()->orderBy('timestamp')->get();
|
||||||
->orderBy('timestamp')
|
|
||||||
->get()
|
// Batch-check which story shares actually still exist in our local
|
||||||
->map(fn (DirectMessage $message) => [
|
// `stories` table, so we only need one query for the whole thread
|
||||||
|
// rather than one per message.
|
||||||
|
$storyPks = $messages
|
||||||
|
->map(fn (DirectMessage $m) => $this->parseStorySharePk($m->shared_url)['item_id'] ?? null)
|
||||||
|
->filter()
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$existingStoryIds = Story::whereIn('id', $storyPks)->pluck('id')->all();
|
||||||
|
|
||||||
|
$messages = $messages->map(function (DirectMessage $message) use ($existingStoryIds) {
|
||||||
|
$storyShare = $this->parseStorySharePk($message->shared_url);
|
||||||
|
|
||||||
|
return [
|
||||||
'id' => $message->id,
|
'id' => $message->id,
|
||||||
'user_id' => $message->user_id,
|
'user_id' => $message->user_id,
|
||||||
'username' => $message->username,
|
'username' => $message->username,
|
||||||
@@ -55,10 +83,17 @@ class ThreadController extends Controller
|
|||||||
'shared_preview_image' => $message->shared_preview_image,
|
'shared_preview_image' => $message->shared_preview_image,
|
||||||
'shared_title' => $message->shared_title,
|
'shared_title' => $message->shared_title,
|
||||||
'shared_caption' => $message->shared_caption,
|
'shared_caption' => $message->shared_caption,
|
||||||
|
'attachments' => $message->attachments,
|
||||||
'reactions' => $message->reactions,
|
'reactions' => $message->reactions,
|
||||||
'replied_to_id' => $message->replied_to_id,
|
'replied_to_id' => $message->replied_to_id,
|
||||||
'replied_to_text' => $message->replied_to_text,
|
'replied_to_text' => $message->replied_to_text,
|
||||||
]);
|
'story_share' => $storyShare ? [
|
||||||
|
'username' => $storyShare['username'],
|
||||||
|
'item_id' => $storyShare['item_id'],
|
||||||
|
'exists_locally' => in_array($storyShare['item_id'], $existingStoryIds, true),
|
||||||
|
] : null,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'thread_id' => $thread->thread_id,
|
'thread_id' => $thread->thread_id,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class DirectMessage extends Model
|
|||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'id', 'thread_id', 'user_id', 'username', 'full_name', 'item_type', 'text',
|
'id', 'thread_id', 'user_id', 'username', 'full_name', 'item_type', 'text',
|
||||||
'media_url', 'is_video', 'timestamp', 'shared_url', 'shared_preview_image',
|
'media_url', 'is_video', 'attachments', 'timestamp', 'shared_url', 'shared_preview_image',
|
||||||
'shared_title', 'shared_caption', 'reactions', 'replied_to_id', 'replied_to_text',
|
'shared_title', 'shared_caption', 'reactions', 'replied_to_id', 'replied_to_text',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ class DirectMessage extends Model
|
|||||||
'is_video' => 'boolean',
|
'is_video' => 'boolean',
|
||||||
'timestamp' => 'integer',
|
'timestamp' => 'integer',
|
||||||
'reactions' => 'array',
|
'reactions' => 'array',
|
||||||
|
'attachments' => 'array',
|
||||||
'scraped_at' => 'datetime',
|
'scraped_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+11
-11
@@ -1,11 +1,12 @@
|
|||||||
import { test } from '@playwright/test';
|
import { test } from '@playwright/test';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
test.use({ storageState: '/home/pwuser/ig-session.json' });
|
test.use({ storageState: path.resolve(__dirname, '.auth', 'ig-session.json') });
|
||||||
|
|
||||||
test('inspect non-text message shapes', async ({ page }) => {
|
test('inspect IMAGES message shape', async ({ page }) => {
|
||||||
test.setTimeout(120_000);
|
test.setTimeout(120_000);
|
||||||
|
|
||||||
const interesting = [];
|
const found = [];
|
||||||
|
|
||||||
page.on('response', async (response) => {
|
page.on('response', async (response) => {
|
||||||
const url = response.url();
|
const url = response.url();
|
||||||
@@ -17,10 +18,8 @@ test('inspect non-text message shapes', async ({ page }) => {
|
|||||||
const t = json?.data?.get_slide_thread_nullable?.as_ig_direct_thread;
|
const t = json?.data?.get_slide_thread_nullable?.as_ig_direct_thread;
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
for (const edge of t.slide_messages?.edges || []) {
|
for (const edge of t.slide_messages?.edges || []) {
|
||||||
const node = edge.node;
|
if (edge.node?.content_type === 'IMAGES') {
|
||||||
const hasReactions = (node.reactions?.length || 0) > 0 || (node.msg_reactions?.length || 0) > 0;
|
found.push(edge.node);
|
||||||
if (node.content_type !== 'TEXT' || hasReactions || node.replied_to_message) {
|
|
||||||
interesting.push({ thread: t.thread_title, node });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) { /* skip */ }
|
} catch (e) { /* skip */ }
|
||||||
@@ -29,13 +28,14 @@ test('inspect non-text message shapes', async ({ page }) => {
|
|||||||
await page.goto('https://www.instagram.com/direct/inbox/');
|
await page.goto('https://www.instagram.com/direct/inbox/');
|
||||||
await page.waitForTimeout(6000);
|
await page.waitForTimeout(6000);
|
||||||
|
|
||||||
const threadHrefs = await page.$$eval('a[href*="/direct/t/"]', els =>
|
const threadHrefs = await page.$$eval('a[href*="/direct/t/"]', (els) => [
|
||||||
[...new Set(els.map(el => el.getAttribute('href')))]);
|
...new Set(els.map((el) => el.getAttribute('href'))),
|
||||||
|
]);
|
||||||
|
|
||||||
for (const href of threadHrefs) {
|
for (const href of threadHrefs) {
|
||||||
await page.goto(`https://www.instagram.com${href}`);
|
await page.goto(`https://www.instagram.com${href}`);
|
||||||
await page.waitForTimeout(3500);
|
await page.waitForTimeout(3500);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[INTERESTING MESSAGES]', JSON.stringify(interesting, null, 2));
|
console.log('[IMAGES MESSAGES]', JSON.stringify(found, null, 2));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,6 +61,18 @@ function extractShare(node) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A raw photo/video message has content_type 'IMAGES' with one or more
|
||||||
|
// attachments (Instagram allows sending several photos in a single message).
|
||||||
|
function extractAttachments(node) {
|
||||||
|
const attachments = node.content?.attachments;
|
||||||
|
if (!attachments?.length) return null;
|
||||||
|
return attachments.map((a) => ({
|
||||||
|
url: a.attachment_cdn_url || a.preview_cdn_url || null,
|
||||||
|
width: a.preview_width || null,
|
||||||
|
height: a.preview_height || null,
|
||||||
|
})).filter((a) => a.url);
|
||||||
|
}
|
||||||
|
|
||||||
function extractMessage(node, thread, fbidLookup) {
|
function extractMessage(node, thread, fbidLookup) {
|
||||||
const senderDict = node.sender?.user_dict;
|
const senderDict = node.sender?.user_dict;
|
||||||
const resolved = senderDict
|
const resolved = senderDict
|
||||||
@@ -85,6 +97,7 @@ function extractMessage(node, thread, fbidLookup) {
|
|||||||
text: node.text_body || node.content?.text_body || null,
|
text: node.text_body || node.content?.text_body || null,
|
||||||
timestamp_ms: node.timestamp_ms ? Number(node.timestamp_ms) : null,
|
timestamp_ms: node.timestamp_ms ? Number(node.timestamp_ms) : null,
|
||||||
share: extractShare(node),
|
share: extractShare(node),
|
||||||
|
attachments: extractAttachments(node),
|
||||||
reactions: reactions.length ? reactions : null,
|
reactions: reactions.length ? reactions : null,
|
||||||
replied_to_id: node.replied_to_message_id || null,
|
replied_to_id: node.replied_to_message_id || null,
|
||||||
replied_to_text: node.replied_to_message?.text_body || null,
|
replied_to_text: node.replied_to_message?.text_body || null,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import Avatar from '@/components/dredge/Avatar.vue';
|
import Avatar from '@/components/dredge/Avatar.vue';
|
||||||
import MessageText from './MessageText.vue';
|
import MessageText from './MessageText.vue';
|
||||||
import MessageShare from './MessageShare.vue';
|
import MessageShare from './MessageShare.vue';
|
||||||
|
import MessageStoryShare from './MessageStoryShare.vue';
|
||||||
|
import MessageMedia from './MessageMedia.vue';
|
||||||
import MessageSystem from './MessageSystem.vue';
|
import MessageSystem from './MessageSystem.vue';
|
||||||
import type { ThreadMessage } from '@/types/dredge.types';
|
import type { ThreadMessage } from '@/types/dredge.types';
|
||||||
|
|
||||||
@@ -26,9 +28,16 @@ defineProps<{
|
|||||||
{{ message.replied_to_text }}
|
{{ message.replied_to_text }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<MessageShare v-if="message.shared_url" :message="message" />
|
<!-- Text is a universal concern shown alongside whatever else the
|
||||||
<MessageText v-else-if="message.text" :message="message" />
|
message carries, not one branch of a type dispatch — a story
|
||||||
<MessageSystem v-else :message="message" :is-own="isOwn" />
|
reply, for instance, has both a caption you typed AND a
|
||||||
|
share preview, and previously only one or the other rendered. -->
|
||||||
|
<MessageText v-if="message.text" :message="message" />
|
||||||
|
|
||||||
|
<MessageStoryShare v-if="message.story_share" :message="message" />
|
||||||
|
<MessageShare v-else-if="message.shared_url" :message="message" />
|
||||||
|
<MessageMedia v-else-if="message.attachments?.length" :message="message" />
|
||||||
|
<MessageSystem v-else-if="!message.text" :message="message" :is-own="isOwn" />
|
||||||
|
|
||||||
<div v-if="message.reactions?.length" class="bubble__reactions">
|
<div v-if="message.reactions?.length" class="bubble__reactions">
|
||||||
<span v-for="(reaction, i) in message.reactions" :key="i" class="bubble__reaction">
|
<span v-for="(reaction, i) in message.reactions" :key="i" class="bubble__reaction">
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { proxyImage } from '@/lib/proxyImage';
|
||||||
|
import type { ThreadMessage } from '@/types/dredge.types';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
message: ThreadMessage;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="message.attachments?.length"
|
||||||
|
class="message-media"
|
||||||
|
:class="{ 'message-media--grid': message.attachments.length > 1 }"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
v-for="(attachment, i) in message.attachments"
|
||||||
|
:key="i"
|
||||||
|
:href="attachment.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<img :src="proxyImage(attachment.url)" alt="" class="message-media__image" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.message-media {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.message-media > a {
|
||||||
|
display: block;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
.message-media__image {
|
||||||
|
display: block;
|
||||||
|
max-width: 220px;
|
||||||
|
max-height: 320px;
|
||||||
|
border-radius: 10px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.message-media--grid {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3rem;
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
|
.message-media--grid .message-media__image {
|
||||||
|
width: 105px;
|
||||||
|
height: 105px;
|
||||||
|
max-width: none;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { proxyImage } from '@/lib/proxyImage';
|
||||||
|
import StoryViewer from '@/components/dredge/Stories/StoryViewer.vue';
|
||||||
|
import type { ThreadMessage } from '@/types/dredge.types';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
message: ThreadMessage;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isOpen = ref(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="message.story_share!.exists_locally ? 'button' : 'a'"
|
||||||
|
class="story-share"
|
||||||
|
:class="{ 'story-share--large': message.story_share!.exists_locally }"
|
||||||
|
v-bind="
|
||||||
|
message.story_share!.exists_locally
|
||||||
|
? { type: 'button' }
|
||||||
|
: { href: message.shared_url ?? undefined, target: '_blank', rel: 'noopener noreferrer' }
|
||||||
|
"
|
||||||
|
@click="message.story_share!.exists_locally && (isOpen = true)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="message.shared_preview_image"
|
||||||
|
:src="proxyImage(message.shared_preview_image)"
|
||||||
|
alt=""
|
||||||
|
class="story-share__image"
|
||||||
|
/>
|
||||||
|
<span v-if="message.shared_title || message.shared_caption" class="story-share__body">
|
||||||
|
<span v-if="message.shared_title" class="story-share__title">
|
||||||
|
{{ message.shared_title }}
|
||||||
|
</span>
|
||||||
|
<span v-if="message.shared_caption" class="story-share__caption">
|
||||||
|
{{ message.shared_caption }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</component>
|
||||||
|
|
||||||
|
<StoryViewer
|
||||||
|
v-if="isOpen && message.story_share"
|
||||||
|
:username="message.story_share.username"
|
||||||
|
:item-id="message.story_share.item_id"
|
||||||
|
@close="isOpen = false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.story-share {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.4rem;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.story-share__image {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.story-share__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.story-share__title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.story-share__caption {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A story we actually have locally: worth a much bigger, tappable preview
|
||||||
|
instead of the compact link-card treatment used for everything else. */
|
||||||
|
.story-share--large {
|
||||||
|
flex-direction: column;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
.story-share--large .story-share__image {
|
||||||
|
width: 160px;
|
||||||
|
height: 284px; /* ~9:16, matching Instagram's own story aspect ratio */
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.story-share--large .story-share__body {
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
.story-share--large .story-share__caption,
|
||||||
|
.story-share--large .story-share__title {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,6 +6,9 @@ import type { StoryDetail } from '@/types/dredge.types';
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
username: string;
|
username: string;
|
||||||
|
// When set, opens this specific (possibly expired) story item directly
|
||||||
|
// instead of the user's current live tray.
|
||||||
|
itemId?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -30,12 +33,15 @@ async function fetchStory() {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/stories/${props.username}`);
|
const url = props.itemId ? `/api/story-items/${props.itemId}` : `/api/stories/${props.username}`;
|
||||||
|
const response = await fetch(url);
|
||||||
if (!response.ok) throw new Error('Failed to load story');
|
if (!response.ok) throw new Error('Failed to load story');
|
||||||
detail.value = await response.json();
|
detail.value = await response.json();
|
||||||
currentIndex.value = detail.value?.start_index ?? 0;
|
currentIndex.value = detail.value?.start_index ?? 0;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = 'Could not load this story.';
|
error.value = props.itemId
|
||||||
|
? 'This story is no longer available.'
|
||||||
|
: 'Could not load this story.';
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ export interface ThreadReaction {
|
|||||||
username: string | null;
|
username: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ThreadStoryShare {
|
||||||
|
username: string;
|
||||||
|
item_id: string;
|
||||||
|
exists_locally: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ThreadMessage {
|
export interface ThreadMessage {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string | null;
|
user_id: string | null;
|
||||||
@@ -90,9 +96,11 @@ export interface ThreadMessage {
|
|||||||
shared_preview_image: string | null;
|
shared_preview_image: string | null;
|
||||||
shared_title: string | null;
|
shared_title: string | null;
|
||||||
shared_caption: string | null;
|
shared_caption: string | null;
|
||||||
|
attachments: { url: string; width: number | null; height: number | null }[] | null;
|
||||||
reactions: ThreadReaction[] | null;
|
reactions: ThreadReaction[] | null;
|
||||||
replied_to_id: string | null;
|
replied_to_id: string | null;
|
||||||
replied_to_text: string | null;
|
replied_to_text: string | null;
|
||||||
|
story_share: ThreadStoryShare | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThreadDetail {
|
export interface ThreadDetail {
|
||||||
|
|||||||
@@ -9,5 +9,6 @@ Route::get('/posts', [PostController::class, 'index']);
|
|||||||
Route::get('/stories', [StoryController::class, 'index']);
|
Route::get('/stories', [StoryController::class, 'index']);
|
||||||
Route::get('/stories/{username}', [StoryController::class, 'show']);
|
Route::get('/stories/{username}', [StoryController::class, 'show']);
|
||||||
Route::post('/story-items/{id}/view', [StoryController::class, 'markViewed']);
|
Route::post('/story-items/{id}/view', [StoryController::class, 'markViewed']);
|
||||||
|
Route::get('/story-items/{id}', [StoryController::class, 'showItem']);
|
||||||
Route::get('/threads', [ThreadController::class, 'index']);
|
Route::get('/threads', [ThreadController::class, 'index']);
|
||||||
Route::get('/threads/{threadId}', [ThreadController::class, 'show']);
|
Route::get('/threads/{threadId}', [ThreadController::class, 'show']);
|
||||||
|
|||||||
Reference in New Issue
Block a user