feat: X 게시글 해시태그 하이라이트 및 클릭 링크 추가

- 해시태그(#xxx): primary 색상 하이라이트, 클릭 시 x.com/hashtag/xxx로 이동
- URL: primary 색상 + 밑줄, 클릭 시 해당 URL로 이동
- 웹(모바일/PC), 앱 모두 적용

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-03-29 14:15:58 +09:00
parent 3cf07a8214
commit 1f3c2f3d9b
3 changed files with 104 additions and 30 deletions

View file

@ -499,13 +499,8 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
// //
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Text( child: _buildLinkedText(
decodeHtmlEntities(schedule.content ?? schedule.title), decodeHtmlEntities(schedule.content ?? schedule.title),
style: const TextStyle(
fontSize: 15,
height: 1.5,
color: AppColors.textPrimary,
),
), ),
), ),
// //
@ -691,6 +686,69 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
); );
} }
/// , URL을 RichText
Widget _buildLinkedText(String text) {
// (#xxx) URL (https://...)
final pattern = RegExp(r'(#[^\s#]+)|(https?://[^\s]+)');
final spans = <InlineSpan>[];
int lastEnd = 0;
for (final match in pattern.allMatches(text)) {
//
if (match.start > lastEnd) {
spans.add(TextSpan(
text: text.substring(lastEnd, match.start),
style: const TextStyle(
fontSize: 15,
height: 1.5,
color: AppColors.textPrimary,
),
));
}
final matched = match.group(0)!;
final isHashtag = matched.startsWith('#');
final url = isHashtag
? 'https://x.com/hashtag/${Uri.encodeComponent(matched.substring(1))}?src=hashtag_click'
: matched;
spans.add(WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: GestureDetector(
onTap: () => _launchUrl(url),
child: Text(
matched,
style: TextStyle(
fontSize: 15,
height: 1.5,
color: AppColors.primary,
decoration: isHashtag ? TextDecoration.none : TextDecoration.underline,
decorationColor: AppColors.primary,
),
),
),
));
lastEnd = match.end;
}
//
if (lastEnd < text.length) {
spans.add(TextSpan(
text: text.substring(lastEnd),
style: const TextStyle(
fontSize: 15,
height: 1.5,
color: AppColors.textPrimary,
),
));
}
return RichText(text: TextSpan(children: spans));
}
/// ///
Widget _buildVerifiedBadge() { Widget _buildVerifiedBadge() {
return SizedBox( return SizedBox(

View file

@ -13,26 +13,37 @@ import Birthday from './Birthday';
function linkifyText(text) { function linkifyText(text) {
if (!text) return null; if (!text) return null;
// URL : http(s):// URL // URL
const urlPattern = /(https?:\/\/[^\s]+|(?:bit\.ly|youtu\.be|t\.co|goo\.gl|tinyurl\.com)\/[^\s]+)/gi; const pattern = /(#[^\s#]+)|(https?:\/\/[^\s]+|(?:bit\.ly|youtu\.be|t\.co|goo\.gl|tinyurl\.com)\/[^\s]+)/gi;
const parts = []; const parts = [];
let lastIndex = 0; let lastIndex = 0;
let match; let match;
while ((match = urlPattern.exec(text)) !== null) { while ((match = pattern.exec(text)) !== null) {
if (match.index > lastIndex) { if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index)); parts.push(text.slice(lastIndex, match.index));
} }
let url = match[0]; const matched = match[0];
const href = url.startsWith('http') ? url : `https://${url}`;
parts.push( if (matched.startsWith('#')) {
<a key={match.index} href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline"> //
{url} const tag = matched.slice(1);
</a> parts.push(
); <a key={match.index} href={`https://x.com/hashtag/${encodeURIComponent(tag)}?src=hashtag_click`} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
{matched}
</a>
);
} else {
// URL
const href = matched.startsWith('http') ? matched : `https://${matched}`;
parts.push(
<a key={match.index} href={href} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
{matched}
</a>
);
}
lastIndex = match.index + match[0].length; lastIndex = match.index + match[0].length;
} }

View file

@ -9,34 +9,39 @@ import { Lightbox } from '@/components/common';
function linkifyText(text) { function linkifyText(text) {
if (!text) return null; if (!text) return null;
// URL : http(s):// // URL
const urlPattern = /(https?:\/\/[^\s]+|(?:bit\.ly|youtu\.be|t\.co|goo\.gl|tinyurl\.com)\/[^\s]+)/gi; const pattern = /(#[^\s#]+)|(https?:\/\/[^\s]+|(?:bit\.ly|youtu\.be|t\.co|goo\.gl|tinyurl\.com)\/[^\s]+)/gi;
const parts = []; const parts = [];
let lastIndex = 0; let lastIndex = 0;
let match; let match;
while ((match = urlPattern.exec(text)) !== null) { while ((match = pattern.exec(text)) !== null) {
//
if (match.index > lastIndex) { if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index)); parts.push(text.slice(lastIndex, match.index));
} }
// URL const matched = match[0];
let url = match[0];
// http(s)://
const href = url.startsWith('http') ? url : `https://${url}`;
parts.push( if (matched.startsWith('#')) {
<a key={match.index} href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline"> const tag = matched.slice(1);
{url} parts.push(
</a> <a key={match.index} href={`https://x.com/hashtag/${encodeURIComponent(tag)}?src=hashtag_click`} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
); {matched}
</a>
);
} else {
const href = matched.startsWith('http') ? matched : `https://${matched}`;
parts.push(
<a key={match.index} href={href} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
{matched}
</a>
);
}
lastIndex = match.index + match[0].length; lastIndex = match.index + match[0].length;
} }
//
if (lastIndex < text.length) { if (lastIndex < text.length) {
parts.push(text.slice(lastIndex)); parts.push(text.slice(lastIndex));
} }